C+++ Primer中文非扫描版


深入 系列 Primer 第三版 著 中中文文版版 潘爱民 张丽 译 Addison-Wesley 中国电力出版社 www.infopower.com.cn Stanley B Lippman Josée Lajoie 译序 这是我心仪已久的一本书 我相信很多读者也有同样的感受 在所有的编程语言中 C++可以说是最为复杂的 它既是一门传统的编程语言 也是一门 新的编程语言 说它是一门传统语言 是因为 C++诞生已将近 20 年的历史了 特别是最近 10 年来 C++得到了快速的发展 C++是计算机软件领域中覆盖面最为广阔的编程语言 并且 与 C++相关的智力投入也是其他任何一门语言所无法比拟的 人们对于 C++的研究已经远远超出 了对于一门编程语言所应有的关注 所以 现在的 C++已经非常成熟 有大量的资源 文档 书籍 源代码等等 可供我们使用 说 C++是一门新的编程语言 是因为在 1998 年 C++由 ISO International Standards Organization 完成了标准化 从此 C++领域有了统一的标准 所 有的编译器都将向标准靠拢 或者说 与标准兼容 这有利于我们写出可移植的 C++代码来 同时 C++标准也统一了 C++标准库 为 C++用户提供了最为基本的基础设施 C++经历了多年 的发展 终于有了一个相对稳定的版本 所以 我们应该用一种新的眼光来看待 C++ 而不再 简单地把 C++认为是 C 语言的超集 本书正是新版本 C++的写照 通过本书 你可以重新审视 C++语言 这是我翻译过程中最为真切的体会 它纠正了我过去对于 C++语言的一些误解 虽 然我从 1993 年开始就一直在使用 C++ 但是直到阅读了这本书之后 我才从真正意义上全面 地认识了 C++语言 本书的权威性无需我多说 看看本书原著的前言 了解了两位作者的背景之后 你就可以 知道 这本书是经验和标准的完美结合 Stanley Lippman 从 1984 年开始一直从事 C++方面的 工作 在 C++的实现与应用方面有着丰富的经验 本书前两个版本的成功也证明了他在阐释 C++语言方面的独到之处 Josée Lajoie 从 1990 年开始成为 C++标准委员会的一名成员 并且 承担了很重要的职务 由于她的参与 毫无疑问 本书一定是与标准兼容的 讲述 C++的书非常多 并且不乏优秀和经典之作 在如此众多的 C++书籍中 本书仍具有 不可替代的地位 我想主要的原因在于本书具有以下几个特色 l 内容广阔 从本书的规模 厚度 就可以看出这一点 C++语言融入了大量优秀的特 性 其内容的丰富程度已经远非 C 语言所能及 在所有的 C++书籍中 本书的覆盖面是最为广 阔的 从最基本的 C++程序设计 到面向对象程序设计 以及基于模板的程序设计 面面俱到 而且讲解细致入微 值得仔细品味 2 许多实际的范例程序 纯粹的技术讲解总是非常枯燥的 但是阅读本书并不感觉枯燥 因为作者在介绍每一部分内容的时候都结合一个实际的例子 读者通过这些例子能够很容易地 掌握相应的技术要点 并且看到每一种技术的实际用法 这是本书之所以引人入胜的重要原因 之一 3 叙述内容的安排 C++是一门多风格的程序设计语言 multi-paradigm Programming language 不仅支持面向对象程序设计 也支持其他的程序设计思想 本书的叙述结构正体现 了 C++的这种特点 作者从程序设计思想的角度分别讲述了 C++的各种语言要素 便读者比较 II 译序 容易抓住 C++语言的本质特征 4 与编译器无关 遵从 C++标准 本书的内容并不特定于某一个 C++编译器实现 而 是适用于所有与 C++标准兼容的编译器 作者在讲解过程中也指出了编译器的一些内部考虑 例如 编译器如何在各种上下文环境中解析重载函数 如何处理除式类型转换 等等 这些内 容有利于加深读者对 C++的理解 5 配套的练习 在每一节讲解之后 作者给出了一些练习 这些练习反映了这一节的中 心内容 读者通过这些练习可以巩固所学的知识 所以 本书也可以被用作教材 用于系统全 面地学习 C++语言 虽然本书书名 C++ Primer 的中文含义是 C++初级读本 但是它绝对不是一本很轻 松的入门教材 特别是关于名字空间 函数重载解析过程 模板机制和泛型算法 generic algorithms 等内容并不是一个 C++初学者能够很快掌握的 如果你以前没有看过其他的 C++ 书籍 那么可能需要反复阅读多遍才能掌握本书讲述的内容 如果你已经有了 C++的基础 比 如 已经看过其他的 C++入门书籍 那么阅读本书可以让你快速掌握 C++的要点 如果你是 一名有多年 C++实践经验的程序员 那么阅读本书可以让你重新理解 C++ 总之 这是一本很 好的学习和参考书籍 值得你反复阅读 但是 正如书名所指示的 它不是一本高级书籍 按 照我个人理解 它的技术水准应该在中等偏深一点的层次上 本书的翻译工作由我和张丽共同完成 张丽完成了初稿的翻译工作 我做了第二遍翻译检 查工作 书中每一句话我都认真检查过 个别地方还修改了原著的一些错误 C++中有些术语 还没有统一的中文说法 对于这些术语的处理 我们尽可能地做到符合中文的语言习惯 读者 可以参考本书最后所附的英汉对照索引 这份索引是由中国电力出版社的诸位编辑手工制作完 成的 他们是刘江 朱恩从 陈维宁 程璐 关敏 刘君 夏平 宋宏 姚贵胜 常虹 乔晶 阎宏 感谢他 她 们的辛勤劳动 在翻译过程中 不断收到读者来信或者来电询问这本书的出版情况 我理解读者对于一本 好书的迫切心情 我的想法是 有关 C++的书籍和资料如此之多 所以 学习 C++不一定非要 阅读这本书 但是它可以加快你学习的步伐 并且帮助你深入而全面地理解 C++ 既然你已经 看到了这本书 那就不要错过吧 这本书不会让你失望的 我坚信这一点 潘爱民 北京大学燕北园 前言 本书第二版和第三版之间的变化非常大 其中最值得注意的是 C++已经通过了国际标 准化 这不但为语言增加了新的特性 比如异常处理 运行时刻类型识别 RTTI 名字空 间 内置布尔数据类型 新的强制转换方式 而且还大量修改并扩展了现有的特性 比如模 板 template 支持面向对象 object-oriented 和基于对象 object-based 程序设计所需 要的类 class 机制 嵌套类型以及重载函数的解析机制 也许更重要的是 一个覆盖面非 常广阔的库现在成了标准 C++的一部分 其中包括以前称为 STL 标准模板库 的内容 新 的 string 类型 一组顺序和关联容器类型 比如 vector list map 和 set 以及在这些类型 上进行操作的一组可扩展的泛型算法 generic algorithm 都是这个新标准库的特性 本书 不但包括了许多新的资料 而且还阐述了怎样在 C++中进行程序设计的新的思考方法 简而 言之 实际上 不但 C++已经被重新创造 本书第三版也是如此 在第三版中 不但对语言的处理方式发生了根本的变化 而且作者本身也发生了变化 首先 我们的人数已经加倍 而且 我们的写作过程也已经国际化了 尽管我们还牢牢扎根 于北美大陆 Stan Lippman 是美国人 Josée Lajoie 是加拿大人 最后 这种双作者关系也 反映了 C++团体的两类主要活动 Stan 现在正在迪斯尼动画公司 Walt Disney Feature Animation *致力于以 C++为基础的 3D 计算机图形和动画应用 而 Josée 正专心于 C++的定 义与实现 同时她也是 C++标准的核心语言小组的主席** 以及 IBM 加拿大实验室的 C++编 译器组的成员 Stan是 Bell 实验室中与 Bjarne Stroustrup C++的发明者 一起工作的早期成员之一 从 1984 年开始一直从事 C++方面的工作 Stan 曾经致力于原始 C++编译器 cfront 的各种实 现 从 1986 年的版本 1.1 到版本 3.0 并领导了 2.1 和 3.0 版本的开发组 之后 他参与了 Stroustrup 领导的 Foundation Research Project 项目中关于程序设计环境的对象模型部分 Josée作为 IBM 加拿大实验室 C++编译器组的成员已经有八年时间了 从 1990 年开始她 成为 C++标准委员会的成员 她曾经担任委员会的副主席三年 日前担任核心语言小组委员 会的主席已经达四年之久 本书第三版是一个大幅修订的版本 不仅反映了语言的变化和扩展 也反映了作者洞察 力和经验的变化 * Stan Lippman 现已受雇于 Microsoft 成为 Visual C++ .Net 的架构设计师 ** Josée Lajoie 现正在滑铁卢大学攻读硕士学位.已不再担任该委员会的主席 现任主席为 Sun 公司的 Steve Clamage IV 译序 本书的结构 本书为 C++国际标准进行了全面的介绍 在此意义上 它是一个初级读本 primer 它提供了一种指导性的方法来描述 C++语言 但是 它也为 C++语言提供了一种简单而温 和的描述 从这个角度来看 它不是一本初级读物 C++语言的程序设计要素 比如异常 处理 容器类型 面向对象的程序设计等等 都在解决特定问题或程序设计任务的上下文环 境中展示出来 C++语言的规则 比如重载函数调用的解析过程以及在面向对象程序设计下 支持的类型转换 本书都有广泛的论述 这似乎超出了一本初级读本的范畴 我们相信 为 了加强读者对于 C++语言的理解 覆盖这些内容是必要的 对于这些材料 读者应该不时地 回头翻阅 而不是一次消化了事 如果开始的时候你发现这些内容比较难以接受或者过于枯 燥 请把它们放到一边 以后再回头来看——我们为这样的章节加上了特殊的记号 阅读本书不需要具备 C 语言的知识 但是 熟悉某些现代的结构化语言会使学习进展更 快一些 本书的意图是作为学习 C++的第一本书 而不是学习程序设计的第一本书 为了确 保这一点 我们会以一个公共的词汇表作为开始 然而 开始的章节涵盖了一些基本的概念 比如循环语句和变量等 有些读者可能会觉得这些概念太浅显了 不必担心 深层的内容很 快就会看到 C++的许多威力来自于它对程序设计新方法的支持 以及对程序设计问题的思考方式 因此 要想有效地学习使用 C++ 不要只想简单地学会一组新的语法和语义 为了使这种学 习更加容易 本书将围绕一系列可扩展的例子来组织内容 这些例子被用来介绍各种语言特 性的细节 同时也说明了这些语言特性的动机所在 当我们在一个完整例子的上下文环境中 学习语言特性时 对这些特性为什么会有用处也就变得很清楚了 它会使我们对于 何时以 及怎样在实际的问题解决过程中使用这些特性 有一些感觉 另外 把焦点放在例子上 可 使读者能够尽早地使用一些概念 随着读者的知识基础被建立起来之后 这些概念会进一步 完整地解释清楚 本书前面的例子含有 C++基本概念的简单用法 读者可以先领略一下 C++ 中程序设计的概貌 而不要求完全理解 C++程序设计和实现的细节 第 1 章和第 2 章形成了一个独立完整的 C++介绍和概述 第一篇的目的是使我们快速地 理解 C++支持的概念和语言设施 以及编写和执行一个程序所需要的基础知识 读完这部分 内容之后 你应该对 C++语言有了一些认识 但是还谈不上真正理解 C++ 这就够了 那是 本书余下部分的目的 第 1 章向我们介绍了语言的基本元素 内置数据类型 变量 表达式 语句以及函数 它将介绍一个最小的 合法的 C++程序 简要讨论编译程序的过程 介绍所谓的预处理器 preprocessor 以及对输入和输出的支持 它给出了多个简单但却完整的 C++程序 鼓励 读者亲自编译并执行这些程序 第 2 章介绍了 C++是如何通过类机制 为基于对象和面向对 象的程序设计提供支持的 同时通过数组抽象的演化过程来说明这些设计思想 另外 它简 要介绍了模板 名字空间 异常处理 以及标准库为一般容器类型和泛型程序设计提供的支 持 这一章的进度比较快 有些读者可能会觉得难以接受 如果是这样 我们建议你跳过这 一章 以后再回过头来看它 C++的基础是各种设施 它们使用户能够通过定义新的数据类型来扩展语言本身 这些 V 译序 新类型可以具有与内置类型一样的灵活性和简单性 掌握这些设施的第一步是理解基本语言 本身 第 3 章到第 6 章 第二篇 在这个层次上介绍了 C++语言 第 3 章介绍了 C++语言预定义的内置和复合数据类型 以及 C++标准库提供的 string complex vector 类数据类型 这些类型构成了所有程序的基石 第 4 章详细讨论了 C++语言 支持的表达式 比如算术 关系 赋值表达式 语句是 C++程序中最小的独立单元 它是第 5 章的主题 C++标准库提供的容器类型是第 6 章的焦点 我们不是简单地列出所有可用的 操作 而是通过一个文本查询系统的实现 来说明这些容器类型的设计和用法 第 7 章到第 12 章 第三篇 集中在 C++为基于过程化的程序设计所提供的支持上 第 7 章介绍 C++函数机制 函数封装了一组操作 它们通常形成一项单一的任务 如 print() 名 字后面的括号表明它是一个函数 关于程序域和变量生命期的概念 以及名字空间设施的 讨论是第 8 章的主题 第 9 章扩展了第 7 章中引入的关于函数的讨论 介绍了函数的重载 函数重载允许多个函数实例 它们提供一个公共的操作 共享一个公共的名字 但是 要求 不同的实现代码 例如 我们可以定义一组 print()函数来输出不同类型的数据 第 10 章介 绍和说明函数模板的用法 函数模板为自动生成多个函数实例 可能是无限多个 提供了一 种规范描述 prescription 这些函数实例的类型不同 但实现方式保持不变 C++支持异常处理设施 异常表示的是一个没有预料到的程序行为 比如所有可用的程 序内存耗尽 出现异常情况的程序部分会抛出一个异常——即程序的其他部分都可以访问到 程序中的某个函数必须捕获这个异常并做一些必要的动作 对于异常处理的讨论跨越了两章 第 11 章用一个简单的例子介绍了异常处理的基本语法和用法 该例子捕获和抛出一个类类型 class type 的异常 因为在我们的程序中 实际被处理的异常通常是一个面向对象类层次 结构的类对象 所以 关于怎样抛出和处理异常的讨论一直继续到第 19 章 也就是在介绍面 向对象程序设计之后 第 12 章介绍标准库提供的泛型算法集合 看一看它们怎样和第 6 章的容器类型以及内 置数组类型互相作用 这一章以一个使用泛型算法的程序设计作为开始 第 6 章介绍的 iterator 迭代器 在第 12 章将进一步讨论 因为它们为泛型算法与实际容器的绑定提供了粘合剂 这一章也介绍并解释了函数对象的概念 函数对象使我们能够为泛型算法中用到的操作符 比 如等于或小于操作符 提供另一种可替换的语义 关于泛型算法在附录中有详细说明 并带 有用法的示例 第 13 章到第 16 章 第四篇 的焦点集中在基于对象的程序设计上——即创建独立的抽 象数据类型的那些类设施的定义和用法 通过创建新的类型来描述问题域 C++允许程序员 在写应用程序时可以不用关心各种乏味的簿记工作 应用程序的基本类型可以只被实现一次 而多次被重用 这使程序员能够将注意力集中在问题本身 而不是实现细节上 这些封装数 据的设施可以极大地简化应用程序的后续维护和改进工作 第 13 章集中在一般的类机制上 怎样定义一个类 信息隐藏的概念 即 把类的公有 接口同私有实现分离 以及怎样定义并封装一个类的对象实例 这一章还有关于类域 嵌 套类 类作为名字空间成员的讨论 第 14 章详细讨论 C++为类对象的初始化 析构以及赋值而提供的特殊支持 为了支持 这些特殊的行为 需要使用一些特殊的成员函数 分别是构造函数 析构函数和拷贝赋值操 作符 这一章我们还将看一看按成员初始化和拷贝的主题 即指一个类对象被初始化为或者 VI 译序 赋值为该类的另一个对象 以及为了有效地支持按成员初始化和拷贝而提出的命名返回值 named return value 扩展 第 15 章将介绍类特有的操作符重载 首先给出一般的概念和设计考虑 然后介绍一些 特殊的操作符 如赋值 下标 调用以及类特有的 new 和 delete 操作符 这一章还介绍了类 的友元 它对一个类具有特殊的访问特权 及其必要性 然后讨论用户定义的转换 包括底 层的概念和用法的扩展实例 这一章还详细讨论了函数重载解析的规则 并带有代码示例说 明 类模板是第 16 章的主题 类模板是用来创建类的规范描述 其中的类包含一个或多个 参数化的类型或值 例如 一个 vector 类可以对内含的元素类型进行参数化 一个 buffer 类 可以对内含的元素类型以及缓冲区的大小进行参数化 更复杂的用法 比如在分布式计算中 IPC 接口 寻址接口 同步接口等 都可以被参数化 这一章讨论了怎样定义类模板 怎样 创建一个类模板特定类型的实例 怎样定义类模板的成员 成员函数 静态成员和嵌套类型 以及怎样用类模板来组织我们的程序 最后以一个扩展的类模板的例子作为结束 面向对象的程序设计和 C++的支持机制是第 17 18 19 和 20 章 第五篇 的主题 第 17 章介绍了 C++对于面向对象程序设计主要要素的支持 继承和动态绑定 在面向对象的程 序设计中 用父/子关系 也称类型/子类型关系 来定义 有共同行为的各个类 类不用 重新实现共享特性 它可以继承了父类的数据和操作 子类或者子类型只针对它与父类不同 的地方进行设计 例如 我们可以定义一个父类 Employee 以及两个子类型 TemporaryEmpl 和 Manager 这些子类型继承了 Employee 的全部行为 它们只实现自己特有的行为 继承的第二个方面 称为多态性 是指父类型具有 引用由它派生的任何子类型 的能 力 例如 一个 Employee 可以指向自己的类型 也可以指向 TemporaryEmpl 或者 Manager 动态绑定是指 在运行时刻根据多态对象的实际类型来确定应该执行哪个操作 的解析能力 在 C++中 这是通过虚拟函数机制来处理的 第 17 章介绍了面向对象程序设计的基本特性 这一章说明了如何设计和实现一个 Query 类层次结构 用来支持第 6 章实现的文本查询系统 第 18 章介绍更为复杂的继承层次结构 多继承和虚拟继承机制使得这样的层次结构成 为可能 这一章利用多继承和虚拟继承 把第 16 章的模板类例子扩展成一个三层的类模板层 次结构 第 19 章介绍 RTTI 运行时刻类型识别 设施 使用 RTTI 我们的程序在执行过程中可 以查询一个多态类对象的类型 例如 我们可以询问一个 Employee 对象 它是否实际指向 一个 Manager 类型 另外 第 19 章回顾了异常处理机制 讨论了标准库的异常类层次机构 并说明了如何定义和处理我们自己的异常类层次结构 这一章也深入讨论了在继承机制下重 载函数的解析过程 第 20 章详细说明了如何使用 C++的 iostream 输入/输出库 它通过例子说明了一般的数 据输入和输出 说明了如何定义类特有的输入输出操作符实例 如何辨别和设置条件状态 如何对数据进行格式化 iostream 库是一个用虚拟继承和多继承实现的类层次结构 本书以一个附录作为结束 附录给出了每个泛型算法的简短讨论和程序例子 这些算法 按字母排序 以便参考 最后 我们要说的是 无论谁写了一本书 他所省略掉的 往往与他所讲述的内容一样 VII 译序 重要 C++语言的某些方面 比如构造函数的工作细节 在什么条件下编译器会创建内部临 时对象 或者对于效率的一般性考虑 虽然这些方面对于编写实际的应用程序非常重要 但 是不适合于一本入门级的语言书籍 在开始写作本书第三版之前 Stan Lippman 写的 Inside the C++ Object Model 参见本前言最后所附的参考文献中的 LIPPMAN96a 包含了许 多这方面的内容 当读者希望获得更详细的说明 特别是讨论基于对象和面向对象的程序设 计 时 本书常常会引用该书中的讨论 本书故意省略了 C++标准库中的某些部分 比如对本地化和算术运算库的支持 C++标 准库非常广泛 要想介绍它的所有方面 则远远超出了本书的范围 在后面所附的参考文献 中 某些书更详细地讨论了该库 见 MUSSER96 和 STROUSTRUP97 我们相信 在 这本书出版之后 一定还会有更多的关于 C++标准库各个方面的书面世 第三版的变化 本书第三版的变化主要是以下四个方面 1.涵盖了语言所增加的新特性 异常处理 运行时刻类型识别 名字空间 内置 bool 类型 新风格的类型强制转换 2.涵盖了新的 C++标准库 包括 complex 和 string 类型 auto_ptr 和 pair 类型 顺序容 器和关联容器类型 主要是 list vector map set 容器 以及泛型算法 3.对原来的文字作了调整 以反映出标准 C++对原有语言特性的精炼 变化以及扩展 语言精炼的一个例子是 现在能够前向声明一个嵌套类型 这在以前是不允许的 语言变化 的一个例子是 一个虚拟函数的派生类实例能够返回一个 基类实例的返回类型 的派生类 这种变化支持一个被称为 clone 或 factory 的方法 关于 clone()虚拟函数 见 17.4.7 节说明 对原有语言特性进行扩展的一个例子是 现在可以显式地指定一个函数模板的一个或多个模 板实参 实际上 模板已经被大大地扩展了 差不多已经成为一个新特性 4.加强了对 C++高级特性的处理和组织方式 尤其是对于模板 类以及面向对象程序 设计 这几年 Stan 从一个相对较小的 C++提供者团体转到了一般的 C++用户团体 这种影响 使他相信 越是深入地了解问题 则程序员越是能够高明地使用 C++语言 因此 在第三版 中 许多情况下 我们已经把焦点转移到如何更好地说明底层特性的概念 以及怎样最好地 使用它们 并在适当的时候指出应该避免的潜在陷阱 C++的未来 在出版这本书的时候 ISO/ANSI++标准委员会已经完成了 C++第一个国际标准的技术 工作 该标准已于 1998 年的夏天由 ISO 公布 C++标准公布之后 支持标准 C++的 C++编译器实现出将很快会推出 随着标准的公布 C++语言的进化将会稳定下来 这种稳定性使得以标准 C++编写的复杂的程序库 可以被用 来解决工业界特有的问题 因此 在 C++世界中 我们将会看到越来越多的 C++程序库 一旦标准被公布 标准委员会仍然会继续工作 当然步伐会慢下来 以解决 C++标准 的用户所提出的解释请求 这会导致对 C++标准作一些细微的澄清和修正 如果需要 国际 VIII 译序 标准将会每五年修订一次 以考虑技术的变化和工业界的需要 C++标准公布五年之后将会发生什么事情我们还不知道 有可能是 一个被工业界广泛 使用的新库组件将被加入到 C++标准库的组件集中 但是 到现在为止 面对 C++标准委员 会已经完成的工作 C++的命运就全掌握在用户的手中了 致谢 特别的感谢总是给予 Bjarne Stroustrup 感谢他给予我们如此美妙的编程语言 以及这 些年他对我们的关心 特别感谢 C++标准委员会成员的奉献和艰苦工作 常常是自愿的 以及他们对 C++所作的贡献 下面这些人为本书稿的各个草稿提供了许多有益的建议 Paul Abrahams Michael Ball Stephen Edwards Cay Horstmann Brian Kernighan Tom Lyons Robert Murray Ed Scheibel Roy Turner 和 Jon Wada 尤其要感谢 Michael Ball 的建议和鼓励 特别感谢 Clovis Tondo 和 Bruce Leung 为本书所作的深刻评论 Stan想把特别的感谢给予 Shyh-Chyuan Huang 和 Jinko Gotoh 感谢他们对 Firebird 给与 的帮助和支持 当然还有 Jon Wada 和 Josée Josée要感谢 Gabby Silberman Karen Bennet 以及 IBM 高级研究中心 the Center for Advanced Studies 的其他小组成员 感谢他们为写作这本书所提供的支持 最大的感谢要给 予 Stan 感谢他带着她经历了这项伟大的冒险活动 最后 我们两个都要感谢编辑们的辛苦工作以及巨大的耐心 Debbie Lafferty 他从一 开始 就为本书操劳 Mike Hendrickson 以及 John Fuller Big Purple 公司在排版上做了精彩 的工作 6.1 节的插图是 Elena Driskill 做的 非常感谢 Elena 使我们能够再版它 第二版的致谢 这本书是无数看不见的手在帮助作者的结果 最由衷地感谢 Barbara Moo 她的鼓励 建议 以及对原手稿无数遍地仔细阅读已经无法评价 特别感谢 Bjarne Stroustrup 持续不断 的帮助和鼓励 以及他给予我们如此美妙的编程语言 还有 Stephen Dewhurst 他在我第一 次学习 C++时给了许多支持 以及 Nancy Wilkinson 另一位 cfront 编写者和 Gummi Bears 的提供者 Dag Brück Martin Carroll William Hopkins Brian Kernighan Andrew Koenig Alexis Layton 以及 Barbara Moo 提供了特别详细和敏锐的建议 他们的评论大大完善了这本 书 Andy Baily Phil Brown James Coplien Elizabeth Flanagan David Jordan Don Kretsch Craig Rubin Jonathan Shopiro Judy Ward Nancy Wilkinson 以及 Clay Wilson 检 查了书稿的各种草稿 提出了许多有益的建议 David Prosser 澄清了 ANSI C 的许多问题 Jerry Schwarz 他实现了 iostream 包 提供了附录 A 所依据的原始文档 在第三版中变成了 第 20 章 非常感谢他对附录的细致检查 感谢 3.0 版本开发小组的其他成员 Laura Eaves George Logothetis Judy Ward 和 Nancy Wilkinson 以下的人对 Addison-Wesley 的手稿作了评论 James Adcock Steven Bellovin Jon Forrest IX 译序 Maurice Herlihy Norman Kerth Darrell Long Victor Milenkovic 以及 Justin Smith 以下的人指出了第一版的各种印刷错误 David Beckedorff Dag Bruck John Eldridge Jim Humelsine Dave Jordan Ami Kleinman Andrew Koenig Tim O'Konski Clovis Tondo 和 Steve Vinoski 我非常感谢 Brian Kernighan 和 Andrew Koenig 提供了大量可用的排版工具 参考文献 [BOOCH94] Booch, Grady, Object-Oriented Analysis and Design, Benjamin/Cummings, RedwoodCity,CA (1994) ISBNO-8053-5340-2. [GAMMA95] Gamma, Erich, Richard Helm, Ralph Johnson, and John Vlissides, Design Patterns, Addison Wesley Longman, Inc., Reading, MA (1995) ISBN 0- 201-63361-2. [GHEZZI97] Ghezzi, Carlo, and Mehdi Jazayeri, Programming Language Concepts, 3rd Edition, John Wiley and Sons, New York, NY (1997) ISBN 0-471-10426-4. [HARBISON88] Samuel P. Harbison and Guy L. Steele, Jr, C: A Reference Manual, 3rd Edition, Prentice-Hall, Englewood Cliffs, NJ (l988) ISBN 0-13-110933-2. [ISO-C++97] Draft Proposed International Standard for Information Systems — Programming Language C++ - Final Draft (FDIS) 14882. [KERNIGHAN88] Kernighan, Brian W., and Dennis M. Ritchie, The C Programming Language, Prentice-Hall, Englewood Cliffs, NJ (1988) ISBN 0-13-110362-8. [KOENIG97] Koenig, Andrew, and Barbara Moo, Ruminations on C++, Addison Wesley Longman, Inc., Reading, MA (1997) lSBN 0-201-42339-l. [LIPPMAN91] Lippman, Stanley, C++ Primer, 2nd Edition, Addison Wesley Longman, Inc., Reading, MA (1991) ISBN 0-201-54848-8. [LIPPMAN96a] Lippman, Stanley, Inside the C++ Object Model, Addison Wesley Longman, Inc., Reading, MA (1996) ISBN 0-201-83454-5. [LIPPMAN96b] Lippman, Stanley, Editor, C++ Gems, a SIGS Books imprint, Cambridge University Press, Cambridge,England (1996) ISBN0-13570581-9. [MEYERS98] Meyers, Scott, Effective C++, 2nd Edition, Addison Wesley Longman, Inc., Reading, MA (1998) ISBN 0-201-92488-9. [MEYERS96] Meyers, Scott, More Effective C++, Addison Wesley Longman, Inc., Reading, MA (1996) ISBN 0-201-63371-X. [MURRAY93] Murray, Robert B., C++ Strategies and Tactics, Addison Wesley Longman, Inc., Reading, MA (1993) ISBN 0-20l-56382-7. [MUSSER96J Musser, David R., and Atul Saini, STL Tutorial and Reference Guide, Addison Wesley Longman, Inc., Reading, MA (1996) ISBN 0-201-63398-l. 第一篇 C++概述 我们编写的程序由两个主要方面组成 1 算法的集合 就是将指令组织成程序来解决某个特定的问题 2 数据的集合 算法在这些数据上操作 以提供问题的解决方案 纵观短暂的计算机发展史 这两个主要方面 算法和数据 一直保持不变 发展演化的 是它们之间的关系 就是所谓的程序设计方法 programming paradigm 在过程化程序设计方法 procedural programming 中 一个问题可直接由一组算法来建 立模型 例如 公共图书馆的资料借阅/登记 check out/check in 系统是由一系列过程表现 出来的 其中两个主要的过程是资料的借阅和登记 这些数据被独立存储起来 我们既可以 在某个全局位置上访问这些数据 或者把数据传递给过程以便它能够访问这些数据 Fortran C 和 Pascal 是三种著名的过程语言 C++也支持过程化程序设计 单独的过程 如 check_in() check_out() over_due() fine()等等 都被称为函数 第三篇将集中讨论 C++对过程化程序 设计方法的支持 尤其将重点讨论函数 函数模板和通用算法 在 20 世纪 70 年代 程序设计的焦点从过程化程序设计方法转移到了抽象数据类型 abstract data type 简写为 ADT 的程序设计上 现在通常称之为基于对象(object based 的程序设计 在基于对象的程序设计方法中 我们通过一组数据抽象来建立问题的模型 在 C++中我们把这些抽象称为类 class 例如 在这种方法下 图书馆资料借阅 登记系统就 由类的对象实例 比如书 借阅者 还书时间 罚款等 之间的相互作用表现出来 以此表 示出图书馆的抽象概念 与每个类相关的算法被称为该类的公有接口 public interface 数 据以私有形式被存储在每个对象中 对数据的访问应与一般的程序代码隔离开来 CLU Ada 和 Modula-2 是三种支持抽象数据类型的程序设计语言 第四篇将说明和讨论 C++对抽象数据 类型程序设计方法的支持 面向对象的程序设计方法通过继承 inheritance 机制和动态绑定 dynamic binding 机 制扩展了抽象数据类型 继承机制是对现有实现代码的重用 动态绑定是指对现有的公有接 口的重用 以前独立的类型现在有了类型/子类型的特定关系 一本书 一盒录像带 一段录 音 甚至孩子的宠物 尽管它们有各自的借阅/登记方式 但都可以成为图书馆的收藏资料 共享的公有接口和私有的数据都放在一个抽象类 图书馆资料 LibraryMaterial 中 每个特 殊的图书馆资料类都从 LibraryMaterial 抽象类继承共享的行为 它们只需要提供与自身行为相 关的算法和数据 Simula Smalltalk 和 Java 是三种支持面向对象程序设计方法的著名语言 第五篇将集中讨论 C++对面向对象程序设计方法的支持 C++是一种支持多种程序设计方法的语言 虽然我们主要把它当作面向对象的语言 但 实际上它也提供对过程化的和基于对象的程序设计方法的支持 这样做的好处是对每个问题 都能够提供最合适的解决方案 事实上 没有一种程序设计方法能够对所有的问题都提供最 好的解决方案 这样做带来的缺点是使得语言过于庞大 复杂 2 第一篇 C++概述 第一篇将对整个 C++进行快速浏览 这样做的一个原因是 它可以提供对语言特性的介 绍 以便我们在完全面对这些特性之前 可以自由地引用语言的各个部分 例如 直到第 13 章我们才会详细介绍类 但如果到那时候才提起类 那么在此之前我们将不得不使用很多非 典型的 不恰当的程序例子 提供快速浏览的第二个原因是从美学的角度出发 除非首先让你领略到贝多芬交响曲的 美丽与博大 否则 无关联的升半音 降半音 八度音符 和弦 等一定会让你厌烦 但 是 只有掌握了这些细节 才有可能创作音乐 程序设计也一样 精通 C++程序设计的基础 是首先要穿过操作符优先级或标准算术转换规则的迷宫 这样做既是必要的 也是非常枯燥 的 第 1 章将首先介绍 C++语言的基本元素 包括内置数据类型 变量 表达式 语句 函 数 它将通过一个最小的 并且是合法的 C++程序 来讨论程序的编译过程 预处理 以及 C++对输入 输出的支持 这一章将给出多个简单但完整的 C++程序 鼓励读者亲自编译并执 行这些程序 第 2 章我们将浏览一个过程化程序 一个基于对象的程序和一个面向对象的程序 它们 都实现了一个数组 一个由相同类型的元素组成的有限元素的集合 然后 我们将这些程 序中的数组抽象与 C++标准库中的向量 vector 类进行比较 同时也将首次介绍标准库中 的通用算法 沿着这条路线 我们还将介绍 C++对异常处理 模板 名字空间的支持 实际 上 这一章对整个 C++语言作了大致的介绍 细节部分将在以后各章节中详细介绍 部分读者可能会感觉第 2 章很难理解 给出的许多资料没有初学者所期望的完整说明 这 些细节在以后的章节中讨论 如果你对某些细节部分感到吃力或失去耐心 建议略读或跳 过它们 等到熟悉这些资料以后 再回头重读这些内容 第 3 章将以传统的叙述方式进行 建议对第 2 章不够适应的读者 从第 3 章开始 1 开 始 本章介绍 C++语言的基本元素 包括内置数据类型 对象的定义 表达式 语句 函数的定义和使用 本章将给出一个最小的合法 C++程序 主要用它来讨论程序的 编译过程 预处理 并将首次介绍 C++对输入 输出的支持 我们还将给出一些简 单但完整的 C++程序 1.1 问题的解决 程序常常是针对某些要解决的问题和任务而编写的 我们来看一个例子 某个书店将每 本售出图书的书名和出版社 输入到一个文件中 这些信息以书售出的时间顺序输入 每两 周店主将手工计算每本书的销售量 以及每个出版社的销售量 报表以出版社名称的字母顺 序排列 以使下订单 现在 我们希望写一个程序来完成这项工作 解决大问题的一种方法 是把它分解成许多小问题 理想情况下 这些小问题可以很容 易地被解决 然后 再把它们合在一起 就可以解决大问题了 如果新分割的小问题解决起 来还是太大 就把它分割得再小一些 重复整个过程 直到能够解决每个小问题 这个策略 就是分而治之 divide and conquer 和逐步求精 stepwise refinement 书店问题可以分解 成四个子问题 或任务 1 读销售文件 2 根据书名和出版社计算销售量 3 以出版社名称对书名进行排序 4 输出结果 我们知道怎样解决第 1 2 和 4 个子问题 因此它们不需要进一步分解 但是 第 3 个子 问题解决起来还是有些大 所以对这个子问题重复我们的做法 继续分解 3a 按出版社排序 3b 对每个出版社的书 按书名排序 3c 在每个出版社的组中 比较相邻的书名 如果两者匹配 增加第一个的数量 删除 第二个 3a 3b 和 3c 所代表的问题 现在都已经能够解决了 由于我们能够解决这些子问题 4 第一章 开始 因此也就能够有效地解决原始的大问题了 而且 我们也知道任务的原始顺序是不正确的 正确的动作序列应该是 l 读销售文件 2 对文件排序——先按出版社 然后在出版社内部按书名排序 3 压缩重复的书名 4 将结果写入文件 这个动作序列就是算法 algorithm 下一步我们把算法转换成一种特定的程序设计语 言——在这里是 C++语言 1.2 C++程序 在 C++中 动作被称为表达式 expression 以分号结尾的表达式被称作语句 statement C++中最小的程序单元是语句 在自然语言中 与此类似的结构就是句子 例如 下面是一 组 C++语句 int book_count = 0; book_count = books_on_shelf + books_on_order; cout << "The value of book_count: " << book_count; 第一条语句是一个声明 declaration 语句 book_count 被称为标识符 identifier 或符 号变量 symbolic variable 简称变量 或者对象 object 它定义了计算机内存的一块 区域 并且与名字 book_count 相关联 被用来存储整数值 0 是一个文字常量 literal constant book_count 被初始化为 0 第二条语句是一个赋值 assignment 语句 它把 books_on_shelf 和 books_on_order 的值 相加 并把结果放入与 book_count 相关联的计算机内存区域中 这里假定 books_on_shelf 和 books_on_order 已经在前面的代码中被声明为整型 并赋了初值 第三条是输出 output 语句 cout 是与用户终端相关联的输出目标 <<是输出操作符 该语句向 cout 即用户终端 先输出用引号括起来的字符串文字 然后输出存储在与名字 books_count 相关联的内存区域中的值 假设此时 books_count 中的值为 11273 那么输出结 果为 the value of book_count: 11273 把语句按逻辑语义分组 就形成了一些有名字的单元 这些单元被称为函数 function 例如 把所有需要读取销售文件的语句组织到一个被称为 readln()的函数中 类似地 我们 可以构成 sort() compact()和 print()函数 在 C++中 每个程序必须包含一个被称作 main()的函数 它是由程序员提供的 并且只 有这样的程序才能运行 下面是按前述算法定义的一种 main()函数 int main() { readIn(); sort(); compact(); 5 第一章 开始 print(); return 0; } C++程序从 main()函数的第一条语句开始执行 在本例中 程序从函数 readln()开始 并 且程序按顺序执行 main()函数中的语句 在执行完 main()函数的最后一条语句之后 程序 正常结束 函数由四部分组成 返回类型 函数名 参数表 以及函数体 前三部分合起来称为函 数原型 function prototype 参数表由小括号括起来 包含一个或多个由逗号分开的参数 函数体由一对花括号括起 来 由程序语句序列构成 在本例中 main()函数的函数体调用 invoke 函数 readIn() sort() compact()和 print(). 当这些函数调用都完成时 下面的语句 return 0 将被执行 return 是 C++预定义的语句 它提供了终止函数执行的一种方法 当 return 语句 提供了一个值时 例如 0 这个值就成为函数的返回值 return value 本例中 返回值为 0 表示 main()函数成功执行完毕 标准 C++中 如果 main()函数没有显式地提供返回语句 则 它缺省返回 0 现在我们来看一下 如果想让我们的程序能够执行起来 我们还需要做哪些准备工作 首先 必须提供函数 readln() sort() compact()以及 print()的定义 下面的哑函数实例已经 足够满足这个要求了 void readIn() { cout << "readIn()\n"; } void sort() { cout << "sort()\n"; } void compact() { cout << "compact()\n"; } void print() { cout << "print()\n"; } void用来指定一个没有返回值的函数 正如上面的定义所示 每个函数在被 main()函数 调用时只会简单地在用户终端上显示它的存在 以后 我们会用真正的实现函数来代替这些 哑函数 这种渐进式生成程序的方法 为控制程序设计中不可避免的错误 提供了一种有效的控 制手段 试图一下子就能写出一个完全成功的程序 几乎是不可能的 程序源文件的名字 一般包括两部分 文件名 例如 bookstore 以及文件后缀 文件后 缀一般用来标识文件的内容 比如文件 bookstore.h 在 C 或 C++中习惯上被称为头 header 文件 标准 C++头文件没有后缀 这是个例 外 而以下文件 bookstore.c 习惯上被当作 C 程序文本文件 但在 UNIX 操作系统中 以下文件 bookstore.c 习惯上被当作 C++程序的文本文件 C++程序文件的后缀在不同的 C++实现产品中是不 6 第一章 开始 同的 特别在 DOS 中大写的字母 C 与小写的字母 c 是不能区分的 其他常用来识别 C++程 序文本文件的后缀还包括 bookstore.cxx bookstore.cpp 类似地 头文件的后缀在 C++的不同实现中也不相同 这也是标准 C++没有指定头文件 后缀的一个原因 请查阅你的编译器的用户指南 以确定在当前平台上使用什么后缀 使用某个文本编辑器 将下面这段完整的程序输入到一个 C++文本文件中 #include using namespace std; void read() { cout << "read()\n"; } void sort() { cout << "sort()\n"; } void compact() { cout << "compact()\n"; } void write() { cout << "write()\n"; } int main() { read(); sort(); compact(); write(); return 0; } iostream是输入 输出流库标准文件 注意它没有后缀 它包含 cout 的信息 这对我们 的程序是必需的 #include 是预处理器指示符 preprocessor directive 它把 iostream 的内 容 读入我们的文本文件中 1.3 节将讨论预处理器指示符 在 C++标准库中定义的名字 如 cout 不能在程序中直接使用 除非在预处理器指示符 #include 后面加上语句 using namespace std; 这条语句被称作 using 指示符 using directive C++标准库中的名字都是在一个称作 std 的名字空间中声明的 这些名字在我们的程序文本文件中是不可见的 除非我们显式地使 它们可见 using 指示符告诉编译器要使用在名字空间 std 中声明的名字 在 2.7 和 2.8 节 中将更进一步讨论名字空间与 using 指示符 1 程序已经被输入到文件 如 prog1.c 中之后 接下来就应将其编译 在 Unix 系统中 按以下步骤进行 $表示系统提示符 $ CC prog1.C 用来调用 C++编译器的命令的名字 在不同的实现中也不相同 在 Windows 中 通常通 1在本书写作时 大约指 1997 年前后——译注 并不是所有的 C++实现都支持名字空间 如果你使用的 C++ 实现不支持名字空间 那么 using 指示符必须要忽略掉 因为本书的许多例子都是用不支持名字空间的 C++ 实现来编译的 所以绝大多数的代码例子都省略了 using 指示符 7 第一章 开始 过选择菜单项来调用命令 CC 是 Unix 工作站上使用 C++编译器的命令名 可以通过参考 手册或系统管理员获得系统的 C++命令名 编译器的一部分工作是分析程序代码的正确性 编译器不能判断程序的语义是否正确 但能够判断出程序形式 form 上的错误 下面是两个常见的程序形式错误 1 语法错误 程序员犯了 C++语言的语法错误 例如 int main ( { // 错误 缺少 readIn(): // 错误 非法字符 sort(); compact(); print(); return 0 // 错误 缺少 ';' } 2 类型错误 在 C++中 每个数据项都有一个相对应的数据类型 例如 10 是一个整 型数值 由双引号括起来的词 hello 是一个字符串 如果为一个需要整型参数的函数提供 了一个字符串 编译器就会报告类型错误 错误消息包含一个行号以及编译器对错误的简要描述 按报告的顺序逐一修正错误 是 个好的习惯 一个简单的错误常常有很多关联影响 会使编译器报告的错误比实际要多得多 因此 一旦错误被改正后 应当马上重新编译 这个循环过程通常被称为编辑一编译一调试 edit compile debug 编译器的第二部分工作是转换正确的程序代码 这种转换被称为代码生成 code generation 典型情况下 它生成目标代码或汇编指令代码 这些代码能够被运行该程序的 计算机所理解 成功编译的结果是一个可执行文件 前面的程序执行时 其输出结果如下 readln() sort() compact() print() C++定义了一组内置的基本数据类型 整数类型 int 浮点数类型 float 字符类 型 char 以及只有 false 和 true 两个值的布尔类型 boolean 每种类型都与 C++语言中 某一个关键字 keyword 相关联 程序中的每个对象都与一个特定的类型相关联 例如 下 面的代码 int age = 10; double price = 19.99; char delimiter = ' '; bool found = false; 定义了四个对象 age price delimiter 和 found 分别是整数类型 双精度浮点数类型 字符类型和布尔类型 每个类型都赋予了一个文字常量初始值 整数 10 浮点数 19.99 空 格字符 布尔值 false 在内置类型之间经常发生隐式的类型转换 Conversion 例如 将一个 double 双精度 型的常量赋给一个 int 型的 age 8 第一章 开始 age = 33.333; 实际上 赋给 age 的是被截断后的整数值 33 [标准转换 standard conversion 以及一 般类型的转换将在 4.14 节中详细讨论 ] C++标准库还提供了一组扩展的基本数据类型 其中包括字符串 string 复数 complex number 向量 vector 和列表 list 例如 // 为了使用 string 对象 下面的头文件是必需的 #include string current_chapter = "Getting Started"; // 为了使用 vector 对象 下面的头文件是必需的 #include vector chapter_titles( 20); current_chapter 是一个字符串对象 被初始化为字符串文字 Getting Started chapter_title 是一个向量 包含有 20 个字符串类型的元素 以下这种特殊语法 vector 指示编译器创建一个能够存放字符串元素的向量类型 要定义一个能够存放 20 个整数的 向量对象 我们可以这样写 vector ivec(20); 本书对向量还将进行更多的描述 无论是一种语言还是它的标准库 都不可能提供实际程序设计环境要求的所有数据类型 因此 现代语言都提供了类型定义工具设施 使我们能够在语言中引入新的类型 这些类型 的用法与内置类型的用法一样方便 在 C++中 这种设施就是类机制 在 C++中 像 string complex vector list 这样的标准库类型都被设计成类 实际上 输入 输出库也是这样的 类设施可能是 C++中最重要的组成部分 第 2 章对整个类机制作了基本的概述性介绍 1.2.1 程序流程控制 缺省情况下 语句将按顺序执行 例如 在前面的程序中 重新列在下面 read()总 是先被执行 然后是 sort() compact() write() int main() { read(); sort(); compact(); write(); return 0; } 然而 如果销售进展得很慢 例如 只有 0 或 1 项 那么就没有必要排序和压缩了 但 是我们仍然需要输出这一项销售记录 或者指出没有销售记录产生 通过条件语句 if 我们可 以完成这项工作 假设已经重写了 readln()函数 使其能够返回读入的项数 // read()返回读入的项数 9 第一章 开始 // 返回值的类型为 int int read() { ... } // ... int main() { int count = read(); // 如果读入的项数大于 1 // 就调用 sort()和 compact() if ( count > 1 ) { sort(); compact(); } if ( count == 0 ) cout << "no sales for this month\n"; else write(); return 0; } 第一个 if 语句给出了在括号中的条件表达式为真的情况下应该执行的动作 在这个被修 改过的程序中 只有在 count 大于 1 的时候 sort() compact()函数才会被调用 在第一个 if 语句中 为两个执行分支 如果条件为真 在这里 即如果 count 等于 0 则简单地输出没行 销售产量 否则 只要 count 不等于 0 就调用 write() 我们将在 5. 3 节中详细讨论 if 语句 第二种非顺序执行的语句是迭代 iterate 或称循环 loop 语句 当条件保持为真的时 候 循环重复执行一条或多条语句 例如 int main() { int iterations = 0; bool continue_loop = true; while ( continue_loop != false ) { iterations++; cout << "the while loop has executed " << iterations << " times\n"; if ( iterations == 5 ) continue_loop = false; } return 0; } 在这个看似人为构造的例子中 while 循环执行 5 次 再到 iterations 等于 5 并且 continue_loop 被赋值为 false 如下语句 iterations++; 10 第一章 开始 将使 iterations 加 1 在 1.5 节中将有更实际的 while 循环的例子 第 15 章将详细讲解循 环语句 1.3 预处理器指示符 头文件通过 include 预处理器指示符 preprocessor include directive 而成为我们程序的 一部分 预处理器指示符用 # 号标识 这个符号将放在程序中该行的最起始一列上 处理 这些指示符的程序被称做预处理器 preprocessor 通常捆绑在编译器中 #include 指示符读入指定文件的内容 它有两种格式 #include #include "my_file.h" 如果文件名用尖括号 < 和 > 括起来 表明这个文件是一个工程或标准头文件 查 找过程会检查预定义的目录 我们可以通过设置搜索路径环境变量或命令行选项来修改这些 目录 在不同的平台上这些方法大不相同 建议你请教同事或查阅编译器手册以获得更进 一步的信息 如果文件名用一对引号括起来 则表明该文件是用户提供的头文件 查找该 文件时将从当前文件目录开始 被包含的文件还可以含有#include 指示符 由于嵌套包含文件的原因 一个头文件可能 会被多次包含在一个源文件中 条件指示符可防止这种头文件的重复处理 例如 #ifndef BOOKSTORE_H #define BOOKSTORE_H /* Bookstore.h 的内容 */ #endif 条件指示符#ifndef 检查 BOOKSTORE_H 在前面是否已经被定义 这里 BOOKSTORE_H 是一个预编译器常量 习惯上预编译器常量往往被写成大写字母 如果 BOOKSTORE_H 在前面没有被定义 则条件指示符的值为真 于是从#ifndef 到#endif 之间的所有语句都被包 含进来进行处理 相反 如果#ifndef 指示符的值为假 则它与#endif 指示符之间的行将被忽 略 为了保证头文件只被处理一次 把如下#define 指示符 #define BOOKSTORE_H 放在#ifndef 后面 这样在头文件的内容第一次被处理时 BOOKSTORE_H 将被定义 从而防止了在程序文本文件中以后#ifndef 指示符的值为真 只要不存在 两个必须包含的头文件要检查一个同名的预处理器常量 这样的情形 这 个策略就能够很好地运作 #ifdef指示符常被用来判断一个预处理器常量是否已被定义 以便有条件地包含程序代 码 例如 int main() { #ifdef DEBUG cout << "Beginning execution of main()\n"; #endif 11 第一章 开始 string word; vector< string > text; while ( cin >> word ) { #ifdef DEBUG cout << "word read: " << word << "\n"; #endif text.push_back( word ); } // ... } 本例中 如果没有定义 DEBUG 实际被编译的程序代码如下 int main() { string word; vector< string > text; while ( cin >> word ) { text.push_back( word ); } // ... } 反之 如果定义了 DEBUG 则传给编译器的程序代码是 int main() { cout << "Beginning execution of main()\n"; string word; vector< string > text; while ( cin >> word ) { cout << "word read: " << word << "\n"; text.push_back( word ); } // ... } 我们在编译程序时可以使用-D 选项 并且在后面写上预处理器常量的名字 这样就能在 命令行中定义预处理器常量 2 $ CC -DDEBUG main.C 也可以在程序中用#define 指示符定义预处理器常量 2对于 UNIX 系统 确实是这样的 Windows 程序员应该检查一下编译器的用户指南 12 第一章 开始 编译 C++程序时 编译器自动定义了一个预处理器名字__cplusplus 注意前面有两个下 划线 因此 我们可以根据它来判断该程序是否是 C++程序 以便有条件地包含一些代码 例如 #ifdef __cplusplus // 不错 我们要编译 C++ // extern "C" 到第 7 章再解释 extern "C" #endif int min( int, int ); 在编译标准 C 时 编译器将自动定义名字__STDC__ 当然 __cplusplus 与__STDC__ 不会同时被定义 另外两个比较有用的预定义名字是 __LINE__和__FILE__ __LINE__记录文件已经被 编译的行数 __FILE__包含正在被编译的文件的名字 可以这样使用它们 if ( element_count == 0 ) cerr << "Error: " << __FILE__ << " : line " << __LINE__ << "element_count must be non-zero.\n"; 另外两个预定义名字分别包含当前被编译文件的编译时间 __TIME__ 和日期 __DATE__ 时间格式为 hh:mm:ss 因此如果在上午 8 点 17 分编译一个文件 则时间表 示为 08:17:05 如果这一天是 1996 年 10 月 31 日 星期四 则日期表示为 Oct 31 1996 若当前处理的行或文件发生变化 则__LINE__和__FILE__的值将分别被改变 其他四个 预定义名字在编译期间保持不变 它们的值也不能被修改 assert()是 C 语台标准库中提供的一个通用预处理器宏 在代码中常利用 assert()来判断一 个必需的前提条件 以便程序能够正确执行 例如 假定我们要读入一个文本文件 并对其 中的词进行排序 必需的前提条件是文件名已经提供给我们了 这样我们才能打开这个文件 为了使用 assert() 必须包含与之相关联的头文件 #include 下面是一个简单的使用示例 assert( filename != 0 ); assert()将测试 filename 不等于 0 的条件是否满足 这表示 为了后面的程序能够正确执 行 我们必须断言一个必需的前提条件 如果这个条件为假 即 filename 等于 0 断言 失败 则程序将输出诊断消息 然后终止 assert.h是 C 库头文件的 C 名字 C++程序可以通过 C 库的 C 名字或 C++名字来使用它 这个头文件的 C++名字是 cassert C 库头文件的 C++名字总是以字母 C 开头 后面是去掉后 缀.h 的 C 名字 正如前面所解释的 由于在各种 C++实现中 头文件的后缀各不相同 因 此标准 C++头文件没有指定后缀 使用头文件的 C 名字 或者 C++名字 两种情况下头文件的#include 预处理器指示符的 效果也会不同 下面的#include 指示符 13 第一章 开始 #include 将 cassert 的内容被读入到我们的文本文件中 但是由于所有的 C++库名字是在名字空间 std 中被定义的 因而在我们的程序文本文件中 它们是不可见的 除非用下面的 using 指示 符显式地使其可见 using namespace std; 使用 C 头文件的#include 指示符 #include 就可以直接在程序文本文件中使用名字 assert() 而无需使用 using 指示符 3 库文件厂 商用名字空间来控制全局名字空间污染 即名字冲突 问题 以避免它们的库 污染 了用 户程序的名字空间 8.5 节将讨论这些细节 1.4 注释 注释是用来帮助程序员读程序的语言结构 它是一种程序礼仪 可以用来概括程序的算 法 标识变量的意义 或者阐明一段比较难懂的程序代码 注释不会增加程序的可执行代码 的长度 在代码生成以前 编译器会将注释从程序中剔除掉 C++中有两种注释符号 一种是注释对 /* */ 与 C 语言中的一样 注释的开始用/* 标记 编译器会把/*与*/之间的代码当作注释 注释可以放在程序的任意位置 可以含有制 表符 tab 空格或换行 还可以跨越多行程序 例如 /* * 这是我们第一次看到 ++的类定义 * 类可用于基于对象和 * 面向对象编程中 screen 类的 * 实现代码在第 13 章中 */ class Screen { /* 此部分称为类体 */ public: void home(); /* 将光标移到 0 0 */ void refresh(); /* 重绘 Screen */ private: /* 类支持"信息隐藏" */ /* 信息隐藏限制了程序 */ /* 对类的内部表示 其数据 的 */ /* 访问 这是用"private" */ /* 来表示的 */ int height, width; }; 在代码中混杂过多的注释会使程序更难于理解 例如 注释几乎淹没了 width 和 height 3 在本书写作时 并不是所有的 C++实现都支持 C 库头文件的 C++名字 因为本书的许多例子是在不支持 C++头文件名的实现中编译的 所以有时候例子代码会用 C 名字引用 C 库头文件 而有时候用 C++ 名字引 用 C 库头文件 14 第一章 开始 的声明 通常 把注释放在要描述的文本之上比较合适 与其他软件文档一样 考虑到有效 性问题 注释必须随着软件的发展而升级 但是 注释与所描述的代码随时间推移而渐行渐 远的情况却是经常发生的 注释对不能嵌套 即一个注释对不能出现在另外一个注释对之中 请尝试在系统中编译 下面的程序 它会使大多数编译器无法正常处理 #include /* * 注释对 /* */ 不能嵌套 * 这里 不能嵌套 几个字将被认为是代码 * 还包括接下来的这几行 */ int main() { cout << "hello, world\n"; } 解决这种嵌套注释的一个办法是在星号和斜线之间加一个空格 / * * / 对于星号和斜线序列 只有当这两个字符之间没有被空格分割时 它们才被看作是注释 符 第二种注释符是双斜线 // 它可用来注释一个单行 程序行中注释符右边的内容都 将被当作注释而被编译器忽略 例如 下面的 Screen 类使用了两种注释 /* * 这是我们第一次看到 C++的类定义 * 类可用于基于对象和 * 面向对象编程中 Screen 类的 * 实现代码在第 13 章中 */ class Screen { // 这部分被称为类体 public: void home(); // 将光标移至 0,0 void refresh(); // 重绘 Screen private: /* 类支持"信息隐藏" */ /* 信息隐藏限制了程序 */ /* 对类的内部表示 其数据 的 */ /* 访问 这是用"private" */ /* 来表示的 */ // private 数据省略. . . }; 大多数程序往往包含两种格式的注释 多行的说明通常被放在注释对中 半行或单行的 注释则由双斜线指出 15 第一章 开始 1.5 输入 输出初步 C++的输入/输出功能由输入/输出流 iostream 库提供 输入/输出流库是 C++中一个面 向对象的类层次结构 也是标准库的一部分 终端输入 也被称为标准输入 standard input 与预定义的 iostream 对象 cin 发音为 see-in 绑定在一起 直接向终端输出 也被称为标准输出 standard output 与预定义的 iostream 对象 cout 发音为 see-out 绑定在一起 第三个预定义 iostream 对象 cerr 发音为 see-err 称为标准错误 standard error 也与终端绑定 cerr 通常用来产生给程序用户的警 告或错误信息 任何要想使用 iostream 库的程序必须包含相关的系统头文件 #include 输出操作符<<用来将一个值导向到标准输出 cout 或标准错误 cerr 上 例如 int v1, v2; // ... cout << "The sum of v1 + v2 = "; cout << v1 + v2; cout << '\n'; 双字符序列 \n 表示换行符 newline 输出换行符时 它结束当前的行 并将随后 的输出导向到下一行 除了显式地使用换行符外 我们还可以使用预定义的 iostream 操纵符 manipulator endl 操纵符在 iostream 上执行的是一个操作 而不只是简单地提供数据 例如 endl 在输出 流中插入一个换行符 然后刷新输出缓冲区 我们一般不写 cout << '\n'; 而是写成 cout << endl; 预定义 iostream 操纵符将在第 20 章中讨论 连续出现的输出操作符可以连接在一起 例如 cout << "The sum of v1 + v2 = " << v1 + v2 << endl; 连续的输出操作符按顺序应用在 cout 上 为了便于阅读 连接在一起的输出操作符 可 以分写在几行 下面的三行组成一条输出语句 cout << "The sum of " << v1 << " + " << v2 << " = " << v1 + v2 << endl; 类似地 输入操作符 >> 用来从标准输入读入一个值 例如 string file_name; // ... cout << "Please enter input and output file names: "; cin >> file_name; 16 第一章 开始 连续出现的输入操作符 也可以连接起来 例如 string ifile, ofile; // ... cout << "Please enter input and output file names: "; cin >> ifile >> ofile; 怎样读入未知个数的输入值呢 在 l.2 节结束的时候 我们已经做过 请看下面的代码 序列 string word; while ( cin >> word ) // ... 在 while 循环中 每次迭代都从标准输入读入一个字符串 直到所有的串都读进来 当 到达文件结束处 end-of-file 时 条件 ( cin >> word ) 为假 第 20 章将解释这是如何发生的 下面是使用这段代码序列的一个例子 #include #include int main() { string word; while ( cin >> word ) cout >> "word read is: " >> word >> '\n'; cout >> "ok: no more words to read: bye!\n"; return 0; } 下面是 James Joyce 的小说 Finnegans Wake 的前五个词 riverrun, past Eve and Adam's 从键盘上输入这些词 程序的输出是 word read is: riverrun, word read is: past word read is: Eve word read is: and word read is: Adam's word read is: ok: no more words to read: bye! 在第 6 章 我们将会看到怎样从各种输入字符串中删除标点符号 1.5.1 文件输入和输出 iostream库也支持文件的输入和输出 所有能应用在标准输入和输出上的操作符 也都 可以应用到已经被打开的输入或输出 或两者兼有 文件上 为了打开一个文件供输入或输 出 除了 iostream 头文件外 还必须包含头文件 #include 为了打开一个输出文件 我们必须声明一个 ofstream 类型的对象 17 第一章 开始 ofstream outfile( "name-of-file" ); 为了测试是否已经成功地打开了一个文件 我们可以写出这样的代码 // 如文件不能打开值为 false if ( ! outfile ) cerr << "Sorry! We were unable to open the file!\n"; 类似地 为了打开一个文件供输入 我们必须声明一个 ifstream 类型的对象 ifstream infile( "name of file" ); if ( ! infile ) cerr << "Sorry! We were unable to open the file!\n"; 下面是一个简单的程序 它从一个名为 in_file 的文本文件中读取单词 然后把每个词写 到一个名为 out_file 的输出文件中 并且每个词之间用空格分开 #include #include #include int main() { ofstream outfile( "out_file" ); ifstream infile( "in_file" ); if ( ! infile ) { cerr << "error: unable to open input file!\n"; return -1; } if ( ! outfile ) { cerr << "error: unable to open output file!\n"; return -2; } string word; while ( infile >> word ) outfile << word << ' '; return 0; } 第 20 章将对 iostream 库进行全面的讨论 包括文件输入和输出 现在 我们对 C++提 供的内容有了大致的了解 下一步 我们将通过使用类和模板设施把新的类型引入到语言中 2 C++浏览 本章将首先讲述 C++对数组类型的支持 数组是相同类型元素的集合 例如整型数 组 可能代表考试的分数 或者字符串数组 可能代表在文本文件中包含的单词 然后 我们会看一看内置数组类型的缺点 以及怎样通过提供一个基于对象的 Array 类型的类来改善这些缺点 在这之后再将其扩展成一个含有特化的 Array 子类型的 面向对象层次结构 最后 我们还要比较一下 Array 类型与 C++标准库的 vector 类 并第一次了解泛型算法 沿着这条路 我们将进一步了解 C++对异常处理 模 板和名字空间的支持 2.1 内置数组数据类型 正如第 1 章所介绍的那样 C++为基本算术数据类型 如整数类型 提供了内置的支持 如 // 声明一个整型对象 ival // 初始化为初始值 1024 int ival = 1024; 同时 它也支持双精度和单精度浮点数据类型 // 声明一个双精度浮点对象 dval // 初始化为初始值 3.14159 double dval = 3.14159; // 声明一个单精度浮点对象 fval // 初始化为初始值 3.14159 float fval = 3.14159; 此外 C++还支持布尔类型以及用来存放字符集中单个元素的字符类型 C++为算术数据类型提供了赋值 一般算术运算以及关系运算的内置支持 算术运算如 加 减 乘 除 关系运算如等于 不等于 小于和大于 例如 int ival2 = ival + 4096; // addition 加 int ival3 = ival2 - ival; // subtraction 减 19 第二章 C++浏览 dval = fval * ival; // multiplication 乘 ival = ival3 / 2; // division 除 bool result = ival2 == ival3; // equality 等于 result = ival2 + ival != ival3; // inequality 不等于 result = fval + ival2 < dval; // less-than 小于 result = ival > ival2; // greater-than 大于 另外 标准库还支持基本类抽象的组合 例如字符串 复数 在 2.7 节之前我们暂时不 考虑标准库提供的 vector 类 在内置数据类型与标准库类的类型之间是复合类型 compound type 特别是指针和数 组类型 我们将在 2.2 节中介绍指针类型 数组 array 是一种顺序容器 它包含单一类型的元素 例如 序列 0 1 1 2 3 5 8 13 21 代表菲波那契数列的前 9 个数 只要给出最前面两个元素 后面的元素依次可以由前 面两个元素相加得出 为了定义和初始化一个数组以便存放这些数 我们可以这样写 int fibon[ 9 ] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 }; 数组对象的名字是 fibon 这是一个包含 9 个元素的整型一维 dimension 数组 第一 个元素为 0 最后一个为 21 通过数组下标 subscript 操作符 我们可以以索引方式访问 数组的元素 例如 为了读取数组的第一个元素 我们可能会这样写 int first_elem = fibon[ 1 ]; // 不正确 不幸的是 这是不正确的 虽然它本身并没有语言错误 在 C++中 数组下标从 0 开始 而不是 1 在位置 1 上的元素实际上是数组的第一个元 素 类似地 位置 0 上的元素才是第一个元素 为了访问数组的最后一个元素 我们总是要 索引到数组长度-1 的位置处的元素 fibon[ 0 ]; // 第一个元素 fibon[ 1 ]; // 第二个元素 fibon[ 8 ]; // 最后一个元素 fibon[ 9 ]; // 喔 用 9 索引的元素不是数组的元素 fibon 的 9 个元素由下标 0 8 索引 初学者常见的错误 就是用位置 1 9 来索引 事实上 这非常普遍 以至于这个错误有了自己专用的名字 一位 偏移 off-by-one 错误 通常 我们用循环来遍历数组中的元素 例如 下面的程序初始化了一个包含 10 个元素 的数组 其值分别从 0 到 9 然后再在标准输出上以降序输出 int main() { int ia[ 10 ]; int index; for ( index = 0; index < 10; ++index ) // ia[0] = 0, ia[1] = 1, 等等 20 第二章 C++浏览 ia[ index ] = index; for ( index = 9; index >= 0; --index ) cout << ia[ index ] << " "; cout << endl; } 两个循环各迭代 10 次 关键字 for 后面的三条语句控制循环 第一条语句向 index 赋 值 0 index = 0; 它只在循环开始真正工作之前被执行一次 第二条语句 index < 10; 表示循环的结束条件 stopping condition 它开始真正的循环序列 如果它的值为真 则与 for 循环相关联的语句 或一组语句 将执行 如果它的值为假 则循环终止 在本例 中 每次当 index 值小于 10 时 会执行如下语句 ia[ index ] = index; 第三条语句 ++index 是对一个算术对象加一的简短写法 它等价于 index = index + 1; 它在与 for 循环相关联的语句执行之后才执行 这里与 for 循环相关联的语句是 把 index 的值赋给以 index 为下标的元素 这第三条语句的执行完成了 for 循环的一次迭代 序列的 每次重复都重新测试条件 条件为假时 循环终止 第 5 章将详细介绍 for 循环 第二 个循环则以相反的顺序输出这些值 虽然 C++对数组类型提供了内置支持 但是这种支持仅限于 用来读写单个元素 的机 制 C++不支持数组的抽象 abstraction 也不支持对整个数组的操作 我们有时会希望对 整个数组进行操作 例如 把一个数组赋值给另外一个数组 对两个数组进行相等比较 或 者想知道数组的大小 size 例如 给出两个数组 我们不能用赋值操作符把一个数组拷 贝到另一个中去 int array0[ 10 ], array1[ 10 ]; // 错误 不能直接把一个数组赋值给另一个数组 array0 = array1; 如果我们希望把一个数组赋值给另外一个 则必须自己写程序 按顺序拷贝每个元素 for ( int index = 0; index < 10; ++index ) array0[ index ] = array1[ index ]; 而且 数组类型本身没有自我意识 它不知道自己的长度 我们必须另外记录数组本身 的这些信息 当我们希望把数组作为一个参数传递给一个函数的时候 问题就出现了 在 C++ 中 数组不同于整数类型和浮点数类型 它不是 C++语言的一等 first-class 公民 数组是 21 第二章 C++浏览 从 C 语言中继承来的 它反映了数据与对其进行操作的算法的分离 而这正是过程化程序设 计的特征 在本章后面部分 我们将会了解一些不同的策略 通过这些策略可以使数组具有 一些额外的公民特权 练习 2.1 为什么内置数组类型不支持数组之间的赋值 支持这种操作需要什么信息 练习 2.2 你认为作为一等公民的数组应该支持什么操作 2.2 动态内存分配和指针 在开始基于对象的设计之前 我们需要暂时偏离一下主题 先来介绍一下 C++程序内存 分配的问题 原因是 我们必须首先介绍在程序执行期间怎样申请和访问内存 否则 没有 办法真正实现我们的设计 并且展示理想的 C++代码 这是本小节的目的 在 C++中 对象可以静态分配——即编译器在处理程序源代码时分配 也可以动态分 配——即程序执行时调用运行时刻库函数来分配 这两种内存分配方法的主要区别是效率与 灵活性之间的平衡准则不同 出于静态内存分配是在程序执行之前进行的 因而效率比较高 但是 它缺少灵活性 它要求在程序执行之前就知道所需内存的类型和数量 例如 利用静 态分配的字符串数组 我们无法很容易地处理和存储任意的文本文件 一般来说 存储未知 数目的元素需要动态内存分配的灵活性 到目前为止 所有的内存分配都是静态的 例如 以下定义 int ival = 1024; 指示编译器分配足够的存储区以存放一个整型值 该存储区与名字 ival 相关联 然后 用数值 1024 初始化该存储区 这些工作都在程序执行之前完成 有两个值与对象 ival 相关联 一个是它包含的值——本例中为 1024 另一个是存放这个 值的存储区的地址 在 C++中 这两个值都可以被访问 当我们写出下面的代码时 int ival2 = ival + 1 我们访问 ival 所包含的值 并把它加 1 然后再用该新值初始化 ival2 在本例中 ival2 初始值为 1025 怎样访问和存储内存地址呢 C++支持用指针类型来存放对象的内存地址值 例如 为了声明一个能存放 ival 内存地 址的指针类型 我们可以这样写 // 一个指向 int 类型的指针 int *pint; C++预定义了一个专门的取地址 address-of 操作符 & 当我们把它应用在一个对 象上时 返回的是对象的地址值 因此 为了将 ival 内存地址值赋给 pint 我们可以这样写 int *ping; pint = &ival; // 把 ival 的地址 pint 22 第二章 C++浏览 为了访问 pint 所指向的实际对象 我们必须先用解引用 dereference 操作符 * 来解 除 pint 的引用 dereference pint 例如 下面我们通过 pint 间接地给 ival 加 1 // 通过 pint 间接地给 ival 加 1 *pint = *pint + 1; 它等价于下面直接对 ival 操作的语句 // 直接给 ival 加 1 ival = ival + 1; 在本例中 使用指针间接地操作 ival 没有什么实际的好处 这样做比直接操作 ival 的效 率要低 而且又容易出错 我们只是用它来简单地介绍一下指针 在 C++中 指针的主要用 处是管理和操纵动态分配的内存 静态与动态内存分配的两个主要区别是 1.静态对象是有名字的变量 我们直接对其进行操作 而动态对象是没有名字的变量 我们通过指针间接地对它进行操作 稍后我们会看到一个例子 2.静态对象的分配与释放由编译器自动处理 程序员需要理解这一点 但不需要做任何 事情 相反 动态对象的分配与释放 必须由程序员显式地管理 相对来说比较容易出错 它通过 new 和 delete 两个表达式来完成 对象的动态分配可通过 new 表达式的两个版本之一来完成 第一个版本用于分配特定类 型的单个对象 例如 int *pint = new int( 1024 ); 分配了一个没有名字的 int 类型的对象 对象初始值为 1024 然后 表达式返回对象在 内存中的地址 接着 这个地址被用来初始化指针对象 pint 对于动态分配的内存 惟一的 访问方式是通过指针间接地访问 new表达式的第二个版本 用于分配特定类型和维数的数组 例如 int *pia = new int[ 4 ]; 分配了一个含有四个整数元素的数组 不幸的是 我们没有办法给动态分配的数组的每 个元素显式地指定一个初始值 分配动态数组时一个常令人迷惑的问题是 返回值只是一个指针 与分配单一动态对象 的返回类型相同 例如 pint 与 pia 的不同之处在于 pia 拥有四元素数组的第一个元素的地 址 而 pint 只是简单地包含单一对象的地址 当用完了动态分配的对象或对象的数组时 我 们必须显式地释放这些内存 我们可以通过使用 delete 表达式的两个版本之一来完成这件事 情 而释放之后的内存则可以被程序重新使用 单一对象的 delete 表达式形式如下 // 删除单个对象 delete pint; 数组形式的 delete 表达式如下 // 删除一个对象数组 delete [] pia; 如果忘了删除动态分配的内存 又会怎么样呢 如果真的如此 程序就会在结束时出现 内存泄漏 memory leak 的问题 内存泄漏是指一块动态分配的内存 我们不再拥有指向这 23 第二章 C++浏览 块内存的指针 因此我们没有办法将它返还给程序供以后重新使用 现在大多数系统提供 识别内存泄漏的工具 可以向系统管理员咨询 对指针类型和动态内存分配讲得这么快 可能会留下很多应该回答的问题 但是 动态 内存分配和指针操作是 C++实际编程基础的一个方面 我们不想推迟到后面再介绍它 在本 章接下去的介绍中 我们将在基于对象的与面向对象的 Array 类的实现中 看到它的用法 8.4 节将详细介绍动态内存分配以及 new 与 delete 表达式的用法 练习 2.3 说出下面定义的四个对象之间的区别 (a) int ival = 1024; (c) int *pi2 = new int( 1024 ); (b) int *pi = &ival; (d) int *pi3 = new int[ 1024 ]; 练习 2.4 下面的代码段是做什么的 有什么严重错误 注意 指针 pia 的下标操作符的用法是正 确的 在 3.9.2 节中我们会解释其理由 int *pi = new int( 10 ); int *pia = new int[ 10 ]; while ( *pi < 10 ) { pia[ *pi ] = *pi; *pi = *pi + 1; } delete pi; delete [] pia; 2.3 基于对象的设计 在本节中 我们将使用 C++的类机制来设计和实现一个数组抽象 我们最初的实现只支 持一个整型数组 以后 我们将用模板机制对这种抽象进行扩展 使其能够支持无限数目的 数据类型 第一步 我们需要决定数组应该提供哪些操作 尽管我们希望能提供所有的操作 但是 我们却不能一次提供所有的功能 下面是第一步所支持操作的集合 1.数组类的实现中有内置的自我意识 首先 它知道自己的大小 2.数组类支持数组之间的赋值 以及两个数组之间的相等和不相等的比较操作 3.数组类应该支持对其所含的值进行下列查询操作 数组中最小值是什么 最大值是什 么 某个特殊的值是否在数组中 如果存在 它占的第一个位置的索引是什么 4.数组类支持自排序 为了便于讨论 假定存在一群用户 他们认为数组支持排序的功 能很重要 而另外一些人对此却不以为然 除了支持数组操作 还必须支持数组本身的机制 包括 5.能够指定长度 以此来创建数组 这个值无需在编译时刻知道 24 第二章 C++浏览 6.能够用一组值初始化数组 7.能够通过一个索引来访问数组中的单个元素 为便于讨论 假设用户强烈要求用数组 下标操作符来实现这项功能 8.能够截获并指出错误的索引值 假设我们认为这很有必要 所以没有询问用户的想法 我们认为这是一个设计良好的数组所必须实现的 我们与潜在用户的讨论已经引起了极大的热情 现在我们要真正实现它 但是怎样把这 个设计转换成 C++代码呢 支持基于对象设计的类的一般形式如下 class classname { public: // 公有操作集合 private: // 私有实现代码 }; 这里 class public 和 private 是 C++语言的关键字 classname 是用户定义的标识符 它 用来命名这个类 以便在后面引用该类 我们将前面设计的类命名为 IntArray 等到我们使 它支持的数据类型更广泛时 再将它改名为 Array 类名代表的是一个新的数据类型 我们可以用它来定义这种类类型的对象 就像用内置 的类型定义对象一样 例如 // 单个 IntArray 类对象 IntArray myArray; // 指向 IntArray 类对象的指针 IntArray *pArray = new IntArray; 类定义包括两个部分 类头 class head 由关键字 class 与相关联的类名构成 类体 class body 由花括号括起来 以分号结束 类头本身也用作类的声明 例如 // 在程序中声明 IntArray 类 但是不提供定义 class IntArray; 类体包含成员定义 以及访问标签 如 public 和 private 类的成员包括 该类能执行的 操作 和 代表类抽象所必需的数据 这些操作称为成员函数 member function 或方法 method 对于 IntArray 类来说 它由以下的内容构成 class IntArray { public: // 相等与不相等操作 #2b bool operator==( const IntArray& ) const; bool operator!=( const IntArray& ) const; // 赋值操作符 #2a IntArray& operator=( const IntArray& ); int size() const; // #1 void sort(); // #4 int min() const; // #3a int max() const; // #3b 25 第二章 C++浏览 // 如值在数组中找到 // 返回第一次出现的索引 // 否则返回-1 int find( int value ) const; // #3c private: // 私有实现代码 }; 成员函数右边的数字对应着我们前面定义的规范表中的条目 我们现在不打算解释参数 表中的 const 修饰符或参数表后面的 const 修饰符 现在还没必要详细解释 但是在实际的程 序中 它们还是必需的 通过使用两个成员访问操作符 member access operator 中的一个 我们可以调用一个 有名字的成员函数 如 min() 这两个操作符为 用于类对象的点操作符 . 以及用于类 对象指针的箭头操作打 -> 例如 为了在数组 myArray 类对象中找到最小值 我们可以 这样写 // 用 myArray 数组中的最小元素来初始化 min_val int min_val = myArray.min(); 为了在动态分配的 IntArray 对象中查找最大值 我们可以这样写 int max_val = pArray->max(); 是的 我们还没介绍怎样用一个长度与一组值来初始化 IntArray 类对象 有个特殊的 称为构造函数 constructor 的成员函数可以完成这些工作 我们将会简要地介绍它 把操作符应用在这些类对象上的方式与应用在内置数据类型上的对象一样 下面 给出 两个 IntArray 对象 IntArray myArray0, myArray1; 赋值操作符可以这样应用 // 调用拷贝赋值成员函数 // myArraya.operator=( myArray1 ) myArray0 = myArray1; 等于操作符的调用如下所示 // 调用等于成员函数 // myArray0.operator==( myArray1 ) if ( myArray0 == myArray1 ) cout << "!!our assignment operator works!\n"; 关键字 private 和 public 控制对类成员的访问 出现在类体中公有 public 部分的成员 在一般程序的任何地方都可以访问它们 出现在私有 private 部分的成员只能在该类的成 员函数或友元 friend 中被访问 我们要到 15.2 节才会解释友元 一般来说 公有成员提供了该类的公有接口 public interface ——即实现了这个类的行 为的操作集合 它包括该类的所有成员函数 或者只包括其中一个子集 私有成员提供私有 实现代码 private implementation ——即存储信息的数据 这种 类的公共接口与私有实现代码的分离 被称为信息隐藏 information hiding 信息隐藏是软件工程中一个非常重要的概念 在后面的章节中将为详细的介绍 简要说来 26 第二章 C++浏览 它为程序提供了两个主要好处 1 如果类的私有实现代码需要修改或扩展 那么 只有相对很小一部分 要求访问这些 实现代码的成员函数 需要修改 而许多使用该类的用户程序无需修改 但是要求重新编译 6.18 节将演示这个过程 2 如果类的私有实现代码有错误 那么通常需要检查的代码数量只局限在相对较少的需 要访问这些实现代码的成员函数上 而无需检查整个程序 哪些数据成员是 IntArray 必需的呢 当声明一个 IntArray 对象时 用户会指定数组大小 我们需要存储它 因此 我们将定义一个数据成员来做到这一点 另外 我们需要实际分配 并存储底层的数组 这将通过 new 表达式来变现 我们将定义一个指针数据成员来存储 new 表达式返回的地址值 class IntArray { public: // ... int size() const { return _size; } private: // 内部数据 int _size; int *ia; }; 由于我们把_size 放在类的私有区内 因此我们有必要声明一个公有成员函数 以便允 许用户访问它的值 由于 C++不允许成员函数与数据成员共享同一个名字 所以在这样的情 况下 一般的习惯是在数据成员名字前面加一个下划线 _ 因此 我们有了公有访问函数 size()和私有数据成员_size 在本书以前的版本中 我们在访问函数前加上 get 或 set 实践证 明这样做有些累赘 尽管这种公有访问函数的用法允许用户读取相应的值 但是这种实现似乎还有些根本的 错误 至少第一眼看上去是这样的 你看出来了吗 考虑下面的语句 IntArray array; int array_size = array.size(); 还有 // 假设_size 是 public 的 int array_size = array._size; 将 array_size 初始化为数组的维数 但是很显然 第一个例子需要一个函数调用 而第 二个只需直接访问内存就行了 一般来说 函数调用比直接访问内存的开销要大得多 因而 信息隐藏是否给程序的执行效率增加了严重的额外负担 或许是阻碍性的负担呢 幸运的是 在一般情况下 回答是 不 C++提供的解决方案是内联函数 inline function 机制 内联函数在它的调用点上被展 开 一般来说 内联函数不会引入任何函数调用 4例如 在 for 循环的条件子句中的 size() 调用 4 然而 实际并不总是这样的 对于编译器来说 内联函数是一种请求 而不是一种保证 参见 7.6 节的讨 论 27 第二章 C++浏览 for ( int index = 0; index < array.size(); ++index ) // ... 并没有真的被调用_size 次 而是在编译期间被内联扩展为下面的一般形式 // array.size()的内联扩展 for ( int index = 0; index < array._size; ++index) // ... 在类定义中被定义的成员函数 如 size() 会被自动当作是内联函数 此外 我们也可以 用 inline 关键字显式地要求一个函数被视为内联函数 7.6 节中有更多关于内联函数的说 明 到目前为止 我们已经提供了 IntArray 类所要求的操作 前面的 1 4 项 但是还没有 提供初始化机制和数组中单个元素的访问方式 5 8 项 程序设计中的一个常见错误是使用事先并没向被正确初始化的对象 实际上 这是一个 极为常见的错误 所以 C++为用户定义的类提供了一种自动初始化机制 类构造函数 class constructor 构造函数是一种特殊的类成员函数 专门用于初始化对象 如果构造函数被定义了 那 么在类的每个对象第一次被使用之前 这构造函数就被自动应用在对象上 构造函数由谁来 定义呢 类的提供者——也就是我们来定义构造函数 为一个类确定必要的构造函数是程序 设计不可缺少的一部分 为了定义一个构造函数 我们只要给它与类相同的名字即可 另外 我们不能给构造函 数指定返回值 但是可以给类定义多个构造函数 尽管它们都具有相同的名字 但只要编译 器能够根据参数表区分它们就行 更一般地 C++支持被称为函数重载 function overloading 的机制 函数重载允许两个 或更多个函数使用同一个名字 限制条件是它们的参数表必须不同 参数类型不同 或参数 的数目不同 根据不同的参数表 编译器就能够判断出 对某个特定的调用应该选择哪一个 版本的重载函数 下面是一组合法的 min()重载函数 这些函数也可以是类成员函数 // 一组 min()重载函数 // 每个函数都有一个特有的参数表 #include ; int min( const int *pia, int size ); int min( int, int ); int min( const char *str ); char min( string ); string min( string, string ); 重载函数在运行时刻的行为与非重载函数完全一样 主要的负担是在编译时刻用来决定 中该调用哪个实例所需要的时间 如果 C++不提供函数重载支持 那么我们就必须为程序中 每个函数都要提供一个独一无二的名字 第 9 章将详细讨论函数重载的内容 我们为 IntArray 类指定了下面三个构造函数 class IntArray { public: explicit IntArray( int sz = DefaultArraySize ); IntArray( int *array, int array_size ); 28 第二章 C++浏览 IntArray( const IntArray &rhs ); // ... private: static const int DefaultArraySize = 12; // ... }; 构造函数 IntArray( int sz = DefaultArraySize ); 被称为缺省构造函数 default constructor 用为它不需要用户提供任何参数 我们 现在不打算解释这个缺省构造函数声明中出现的关键字 explicit 之所以显示它仅仅是为了完 整性 如果程序员提供参数 则该值将被传递给构造函数 例如 IntArray array1( 1024 ); 将参数 1024 传递给构造函数 另一方面 如果用户不指定长度 那么构造函数将使用 DefaultArraySize 的值 例如 IntArray array2; 将导致用 DefaultArraySize 的值来调用构造函数 被声明为 static 的数据成员是一类特 殊的共享数据成员 无论这个类的对象被定义了多少个 静态数据成员在程序中也只有一份 这是在类的所有对象之间共享数据的一种方式 13.5 节有全面的讨论 下面是缺省构造函数的一个简化实现版本 简化到没有考虑出错的可能性 可能出现 什么错误呢 本例中有两个 首先 提供给程序的动态内存不是无限的 因此 new 表达式 有可能失败 2.6 节中将介绍如何处理这种情况 第二 传递给参数 sz 的值可能是无效的 例如 负数或 0 或者一个很大的值 以至于无法存储在 int 类型的变量中 IntArray:: IntArray( int sz ) { // 设置数据成员 size = sz; ia = new int[ _size ]; // 初始化内存 for ( int ix=0; ix < _size; ++ix ) ia[ ix ] = 0; } 这是我们第一次在类体的外面定义类的成员函数 惟一的语法区别是要指出成员函数属 于哪个类 这可以通过类域操作符 class scope operator 来实现 IntArray:: 双冒号 :: 操作符被称为域操作符 scope operator 当与一个类名相连的时候 像上 面例子中那样 它就成为一个类域操作符 我们可以非正式地把域看作是一个可视窗口 全 局域的对象在它被定义的整个文件里 一直到文件末尾 都是可见的 在一个函数内被定义 的对象是局域的 local scope 它只在定义其的函数体内可见 每个类维持一个域 在这 个域之外 它的成员是不可见的 类域操作符告诉编译器 后面的标识符可在该类的范围内 29 第二章 C++浏览 被找到 本例中 IntArray:: IntArray( int sz ) 告诉编译器 IntArray()函数被定义为 IntArray 类的成员 尽管程序域不是我们现在应该 关注的事情 但是最终我们还是要理解域的概念 在第 8 章中我们将详细讲解程序域 而类 域将在 13.9 节中特别讨论 IntArray 类的第二个构造函数用内置的整数数组初始化一个新的 IntArray 类对象 它需 要两个参数 一个是实际的数组 另一个参数指明数组的大小 例如 int ia[10] = {0,1,2,3,4,5,6,7,8,9}; IntArray iA3(ia,10); 这个构造函数的实现几乎与第一个构造函数相同 这里我们又一次没有保护自己的 代码 IntArray:: IntArray( int *array, int sz ) { // 设置数据成员 size = sz; ia = new int[ _size ]; // 拷贝数据成员 for ( int ix=0; ix < _size; ++ix ) iz[ix ] = array[ ix ]; } 最后一个 IntArray 构造函数用另外一个 IntArray 对象来初始化当前的 IntArray 对象 对 于下面两种形式 无论是哪一种 它都将被自动调用 IntArray array; // 等价的初始化方式 IntArray ia1 = array; IntArray ia2( array ); 这种构造函数被称为类的拷贝构造函数 copy constructor 在后面的章节中我们将会 看到更多的示例 下面的实现再次忽略了可能出现的运行时刻程序异常 IntArray:: IntArray( const IntArray &rhs ) { // 拷贝构造函数 _size = rhs._size; ia = new int[ _size ]; for (int ix = 0; ix < _size; ix++ ) iz[ ix ] = rhs.ia[ ix ]; } 本例引入了一种新的复合类型 引用 reference 即 IntArray &rhs 引用是一种没有 指针语法的指针 因此 我们写成 rhs._size 而不是 rhs->_size 与指针一样 引用提供 对对象的间接访问 3.6 节中我们将对指针和引用作更多的介绍 30 第二章 C++浏览 注意 这三个构造函数都是以相似的方式来实现的 一般来说 当两个或多个函数重复 相同的代码时 就会将这部分代码抽取出来 形成独立的函数 以便共享 以后 如果需要 改变这些实现 则只需改变一次 而且 这种实现的共享本质更容易为大家所理解 怎么样把构造函数中的代码抽取出来形成独立的共享函数呢 下面是一种可能的实现 class IntArray { public: // ... private: void init( int sz, int *array ); // ... }; void IntArray:: init( int sz, int *array ) { _size = sz; ia = new int[ _size ]; for ( int ix=0; ix < _size; ++ix ) if ( ! array ) ia[ ix ] = 0; else ia[ ix ] = array[ ix ]; } 三个构造函数可重写为 IntArray::IntArray( int sz ){ init( sz, 0 ); } IntArray::IntArray( int *array, int sz ) { init( sz, array ); } IntArray::IntArray( const IntArray &rhs ) { init( rhs.size, rhs.ia ); } 类机制还支持特殊的析构成员函数 destructor member function 每个类对象在被程序 最后一次使用之后 它的析构函数就会被自动调用 我们通过在类的名字前面加一个波浪线 ~ 来标识析构函数 一般地 析构函数会释放在类对象使用和构造过程中所获得的资源 例如 在 IntArray 的析构函数中 它会删除构造时分配的内存 我们将在第 14 章详细讨论 构造函数和析构函数 下面是我们的实现 class IntArray { public: // 构造函数 explicit IntArray( int size = DefaultArraySize ); IntArray( int *array, int array_size ); IntArray( const IntArray &rhs ); // 析构函数 ~IntArray() { delete [] ia; } // ... private: 31 第二章 C++浏览 // ... }; 除非用户能够很容易地通过索引访问单个元素 否则数组类就没有更实际的用处 例如 我们的类需要支持下面的一般用法 IntArray array; int last_pos = array.size()-1; int temp = array[ 0 ]; array[ 0 ] = array[ last_pos ]; array[ last_pos ] = temp; 我们通过提供 专用于一个类的下标操作符实例 来支持索引 IntArray 类对象 下面 是支持这种用法的一个实现 #include int& IntArray:: operator[]( int index ) { assert( index >= 0 && index < size ); return ia[ index ]; } 一般而言 C++语言支持操作符重载 operator overloading 这样就可以为特定的类 类 型 定义新的操作符实例 典型地 类提供一个或多个赋值操作符 等于操作符 可能还有 一个或多个关系操作符 以及 iostream 输入和输出操作符 3.15 节有关于操作符重载的更 进一步的说明 在第 15 章我们将详细讨论操作符重载 类定义以及相关的常数值或 typedef 名通常都存储在头文件中 并且头文件以类名来命 名 因此 假如我们创建一对头文件 IntArray.h 和 Matrix.h 则所有打算使用 IntArray 类或 Matrix 类的程序就都必须包含相关的头文件 类似地 不在类定义内部定义的类成员函数都存储在与类名同名的程序文本文件中 例 如 我们将创建一对程序文本文件 IntArray.C 和 Matrix.C 用来存储相关类的成员函数 记住 程序文本文件的后缀因编译系统而不同 你应该检查自己的系统所使用的命名习惯 这些函数不用随每个使用相关类的程序而重新编译 这些成员函数经过预编译之后被保存在 类库中 iostream 库就是这样一个例子 练习 2.5 C++类的关键特征是接口与实现的分离 接口是一些 用户可以应用到类对象上的操作 的集合 它由三部分构成 这些操作的名字 它们的返回值 以及它们的参数表 一般地 这 些就是该类用户所需要知道的全部内容 私有实现包括为支持公有接口所必需的算法和数据 理想情况下 即使类的接口增长了 它也不用变得与以前的版本不相兼容 另一方面 在类的 生命周期内其实现可以自由演化 从下面选择一个抽象 指类 并为该类编写一个公共接口 (a) Matrix (c) Person (e) Pointer (b) Boolean (d) Date (f) Point 32 第二章 C++浏览 练习 2.6 构造函数和析构函数是程序员提供的函数 它们既不构造也不销毁类的对象 编译器自 动把它们作用到这些对象上 因此构造函数 constructor 和析构函数 destructor 这两个词 多少有些误导 当我们写 int main() { IntArray myArray( 1024 ); // ... return 0; } 在构造函数被应用之前 用于维护 myArray 中数据成员的内存已经被分配了 实际上 编译器在内部把程序转换成如下的代码 注意这不是合法的 C++代码5 int main() { IntArray myArray; // 伪 C++代码--应用构造函数 myArray.IntArray::IntArray( 1024 ); // ... // 伪 C++代码--应用析构函数 myArray.IntArray::~IntArray(); return 0; } 类的构造函数主要用来初始化类对象的数据成员 析构函数主要负责释放类对象在生命 期内申请到的所有资源 请定义在练习 2.5 中选择的类所需要的构造函数集 你的类需要析 构函数吗 练习 2.7 在练习 2.5 和练习 2.6 中 你差不多已经定义了使用该类的完整公有接口 我们还需 要定义一个拷贝赋值操作符 但是现在我们忽略这个事实——C++为 从一个类对象向另一 个类对象赋值 提供了缺省支持 问题在于 缺省的行为常常是不够的 这将在 14.6 节中讨 论 写一个程序来实践在前面两个练习中定义的公有接口 用起来容易还是麻烦 你希望 重写这些定义吗 你能在重写的同时保持兼容性吗 2.4 面向对象的设计 max()与 min()函数的实现没有对数组元素的存储做特殊的假设 因此 我们需要检查数 组的每个元素 如果我们要求所有的元素已经排序 则这两个操作就变得非常简单 只要索 引第一个元素和最后一个元素即可 而且 如果已知元素已经排序 那么查找一个元素的存 在就会更加高效 但是 对数组进行排序增加了 Array 类实现的复杂性 我们的设计出错了 吗 5 对于感兴趣的读者 可以参见本书的姐妹篇 Inside the C++ Object Model 该书讨论了这方面的内容 33 第二章 C++浏览 实际上 我们现在是否犯了错误与我们做出的选择息息相关 排序的数组是一种特殊的 实现 需要时 它完全必要 否则 支持排序数组的额外开销就是一项负担 我们的实现是 比较通用的 在大多数情况下是足够的 它支持更广阔范围内的用户 不幸的是 如果用户 绝对需要排序数组的行为 那么我们的实现就不能提供支持 对用户来说 他没有办法对 min() max()以及 find()这些函数比较通用的实现进行改写 实际上 我们选择的通用实现 并不适合特殊的环境 另一方面 我们的实现对另外一类用户而言又针对性太强了 对索引的范围检查为每次 访问元素增加了额外的负担 在设计中 我们没有考虑这样的开销 2.3 节中第 8 条 而 是假设 如果结果不正确 那么速度再快也没有价值 但是 这种设计至少对于某一类主要 用户 实时虚拟和虚拟现实提供商 就不成立 在这种情况下 数组代表复杂 3D 几何图形 的顶点 场景飞快地变化 以至于一些偶然的错误不会被看到 但如果访问速度太慢了 那 么实时效果就会被打破 我们实现的数组类虽然比没有范围检查的数组类会更安全 但是在 这样的实时应用领域却不够实际 我们怎样才能支持这三种用户的需要呢 解决的方案已经多多少少体现在代码中了 例 如 范围检查局限在下标操作符中 去掉 check.range()的调用 重新命名该数组 现在我们 就有了两种实现 一个有范围检查 一个没有范围检查 进一步拷贝一份代码 并把它修改 成针对已排序的数组 现在我们就有了对已排序数组的支持 // 未排序 也没有边界检查 class IntArray{ ... }; // 未排序 但支持边界检查 class IntArrayRC{ ... }; // 已排序 但没有边界检查 class IntSortedArray{ ... }; 这种方案的缺点是什么呢 1.我们必须维护三个包含大量重复代码的数组实现 我们更希望把这些公共代码只保留 一份 然后由 这三个数组类 以及其他一些我们以后会选择支持的数组类 共享 比 如 可能会是一个带有边界检查的排序数组 2.由于三个数组实现是完全独立的类型 所以我们必须编写独立的函数来操作它们 尽 管函数内的一般性操作都是相同的 例如 void process_array( IntArray& ); void process_array( IntArrayRC& ); void process_array( IntSortedArray& ); 我们希望只编写一个函数 它不但能接受现有的数组类 而且 还能够接受任意将来的 数组类 只要同样的操作集合也能够应用到这些类上 面向对象的程序设计方法正是为我们提供了这样一种能力 上面第 1 项可由继承 inheritance 机制提供 当一个 IntArrayRC 类 也就是一个带有范围检查的 IntArray 类 继承了 IntArray 类时 它就可以访问 IntArray 的数据成员和成员函数 而不要求我们维护两 份代码拷贝 新的类只需提供实现其额外语义所必需的数据成员和成员函数 在 C++中 被继承的类 如本例中的 IntArray 被称作基类 base class 新类被称作 从基类派生 derived 而来 我们把它叫做基类的派生类 derived class 或子类型 subtype 34 第二章 C++浏览 我们说 IntArrayRC 是一种有特殊行为的 IntArray 它支持对索引值的范围检查 子类型与基 类共享公共的接口 common interface ——公有操作的公共集 由于共享公共接口 允许了 子类和基类在程序内部可互换使用 而无需考虑对象的实际类型 从某种意义上来说 公共 接口封装了单个子类型中与类型相关的细节 类之间的类型/子类型关系形成了继承或派生层 次关系 inheritance or derivation hierarchy 例如 下面的非成员函数 swap()把指向基类 IntArray 对象的引用作为第一个参数 该函数交换索引 i 和 j 处的元素 #include void swap( IntArray &ia, int i, int j ) { int tmp = ia[ i ]; ia[ i ] = ia[ j ]; ia[ j ] = tmp; } 下面是 swap()函数的三个合法调用 IntArray ia; IntArrayRC iarc; IntSortedArray ias; // ok ia 是一个 IntArray swap( ia, 0, 10 ); // ok: iarc 是 IntArray 的子类型 swap( iarc, 0, 10 ); // ok: ias 也是 IntArray 的子类型 swap( ias, 0, 10 ); // error: string 不是 IntArray 的子类型 string str( "not an IntArray!" ); swap( str, 0, 10 ); 三个数组类都提供了自己的下标操作符实现 当然 我们的要求是 当调用如下函数时 swap(iarc, 0, 10); IntArrayRC 的下标操作符被调用 当调用如下函数时 swap( ias, 0, 10 ); IntSortedArray 下标操作符被调用等等 swap()调用的下标操作符必须潜在地随着每次调 用而改变 它必须由被交换元素的数组的实际类型来决定 在 C++中 这可以由虚拟函数 virtual function 机制来自动完成 为使 IntArray 类能够被继承 我们需要在语法上做一点小小的改变 必须 可选择的 减少封装的层次 以便允许派生类访问非公有的实现 而且我们也必须显式地指明哪些函数 应该是虚拟的 最重要的变化在于我们如何把一个类设计成为基类 在基于对象的程序设计中 通常类的提供者只有一个 但是类的用户有许多个 提供者 设计并且通常也会实现类 用户使用提供者提供的公有接口 行为的分离可通过将类分成公 35 第二章 C++浏览 有与私有访问级别而反映出来 在继承机制下有多个类的提供者 一个提供基类实现 可能还有一些派生类 另外一 个或多个提供者在继承层次的生命周期内提供派生类 这种行为也是一种实现行为 于类的 提供者经常 但并不总是 需要访问基类的实现 为了提供这种能力 同时还要防止对基类 实现的一般性访问 C++提供了另外一个访问级别 保护 protected 级别 在类的保护区 域内的数据成员和成员函数 不提供给一般的程序 只提供给派生类 放在基类的私有区 域内的成员只能供该类自己使用 派生类不能使用 下面是修改过的 IntArray 类 class IntArray { public: // 构造函数 explicit IntArray( int size = DefaultArraySize ); IntArray( int *array, int array_size ); IntArray( const IntArray &rhs ); // 虚拟析构函数 virtual ~IntArray() { delete [] ia; } // 等于和不等于操作 bool operator==( const IntArray& ) const; bool operator!=( const IntArray& ) const; IntArray& operator=( const IntArray& ); int size() const { return _size; } // 去掉了索引检查功能 . . . virtual int& operator[](int index) { return ia[index]; } virtual void sort(); virtual int min() const; virtual int max() const; virtual int find( int value ) const; protected: // 参见 13.5 节的说明 static const int DefaultArraySize = 12; void init( int sz, int *array ); int _size; int *ia; }; 在面向对象与基于对象的设计中 指明一个类的成员是 public 的准则没有变化 重新设 计的 IntArray 类将被用作基类 它仍然把构造函数 析构函数 下标操作符 min()和 max() 等等声明为公有成员 这些成员继续提供公有接口 但现在接口不只为 IntArray 类服务 同 时也为从它派生的整个继承层次服务 非公有的成员到底该声明为 protected 还是 private 类成员是新的设计准则 如果希望防 止派生类直接访问某个成员 我们就把该成员声明为基类的 private 成员 如果确信某个成员 提供了派生类需要直接访问的操作或数据存储 而且通过这个成员 派生类的实现会更有效 则我们把该成员声明为 protected 对于 IntArray 类 我们已经将全部数据成员设置成 protected 36 第二章 C++浏览 也就是实际上允许后续派生的类访问 IntArray 的实现细节 为了把一个类设计成基类 要做的第二个设计考虑是找出类型相关的成员函数 并把这 些成员函数标记为 virtual 虚拟的 对于类型相关的成员函数 它的算法由特定的基类或派生类的行为或实现来决定 例如 对每种数组类型 下标操作符的实现是不同的 所以 我们将它声明为 Virtual 等于 不等于操作符和 size()成员函数的实现对于其应用的数组类型来说是独立的 因此 不把它声明成 Virtual 对于一个非虚拟函数的调用 编译器在编译时刻选择被调用的函数 而虚拟函数调用的 决定则要等到运行时刻 在执行程序内部的每个调用点上 系统根据被调用对象的实际基类 或派生类的类型来决定选择哪一个虚拟函数实例 例如 考虑下面的代码 void init( IntArray &ia ) { for ( int ix = 0; ix < ia.size(); ++ix ) ia[ ix ] = ix; } 形式参数 ia 可以引用 IntSortedArray IntArrayRC 或 IntArray 类的对象 我们将简要介 绍这里的派生类 函数 size()作为非虚拟函数 由编译器处理并内联展开 但是 下标操 作符要直到执行循环的每次迭代时才能被处理 因为在编译期间编译器不知道数组 ia 指向的 实际类型 第 17 章将详细讨论虚拟函数 包括虚拟析构函数的主题 以及使用虚拟函数设计带来 的效率问题 LIPPMAN96a 对虚拟函数的实现与效率有更深入的讨论 一旦我们定好了设计方案 C++的实现就很容易了 例如 下面这个完整的 IntArrayRC 派生类定义 被放在一个独立的头文件 IntArrayRC.h 中 该文件包含头文件 IntArray.h 而 IntArray.h 包含有 IntArray 类的定义 #ifndef IntArrayRC_H #define IntArrayRC_H #include "IntArray.h" class IntArrayRC : public IntArray { public: IntArrayRC( int sz = DefaultArraySize ); IntArrayRC( int *array, int array_size ); IntArrayRC( const IntArrayRC &rhs ); virtual int& operator[]( int ); private: void check_range( int ); }; #endif IntArrayRC 只需定义不同于 IntArray 实现的那些方面 或者加上对 IntArray 扩展的实现 1 它必须提供自己的下标操作符实例 以支持范围检查 37 第二章 C++浏览 2 它必须提供一个操作来做实际的检查工作 由于它不是公有接口的一部分 所以我们 把它声明为 private 3 它必须提供一组自动初始化函数 即自己的构造函数集 IntArray 的成员函数与数据成员对于 IntArrayRC 来说都是可用的 就如同 IntArrayRC 已经显式地定义了它们一样 这正是下面这句话的含义 class IntArrayRC : public IntArray 冒号定义了 IntArrayRC 是从 IntArray 派生而来的 关键字 public 表明派生类共享基类 的公有接口 IntArrayRC 类型的对象可以用在任何 可以使用基类类型对象 的位置上 比 如在 swap()例子中 第 18 章会详细解释这一点 IntArrayRC 可以看作是 IntArray 的扩展 它增加了下标范围检查的额外特性 下面是下标操作符的一个实现 inline int& IntArrayRC::operator[]( int index ) { check_range( index ); return ia[ index ]; } 这里 check_range()被实现为一个内联成员函数 它调用 assert()宏 关于 assert()宏的讨 论见 1.3 节 #include inline void IntArrayRC::check_range( int index ) { assert( index >= 0 && index < size ); } 我们把 check_range()函数作为一个独立的函数 以便说明私有成员函数并且将范围检 查的处理封装起来 方便我们以后改变边界错误的处理方式 或是用异常处理代替 assert() 派生类对象实际上由几部分构成 每个基类是一个类的子对象 subobject 它在新定 义的派生类中有独立的一部分 派生类对象的初始化过程是这样的 首先自动调用每个基类 的构造函数来初始化相关的基类子对象 然后再执行派生类的构造函数 从设计的角度来看 派生类的构造函数应该只初始化那些在派生类中被定义的数据成员 而不是某类中的数据成 员 虽然我们引入了与类相关的下标操作符版本 以及一个私有的 check_range()辅助函数 但是我们并没有引入需要初始化的额外数据成员 因此 我们可以合理地假设.继承基类的 构造函数已经足够了 我们不需要再提供 IntArrayRC 的构造函数——因为不需要它们做任何 事情 但是 实际上 我们还是需要提供 IntArrayRC 的构造函数 因为基类的构造函数并没有 被派生类继承 析构函数和拷贝赋值操作符同样也没有 还因为我们需要某个接口 以便 通过这个接口把必要的参数传递给某类 IntArray 的构造函数 例如 假设我们定义了一个 IntArrayRC 对象 int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13 }; 38 第二章 C++浏览 IntArrayRC iarc( ia, 8 ); 怎样才能把 ia 和 8 传递给基类的构造函数呢 不可否认 如果 IntArray 构造函数被继承 了 那么就没有这个问题 实际上 那样的话我们会有其他更严重的问题 但现在没有足够的 篇幅向你证明这一点 无论如何 派生类构造函数的语法提供了向基类构造函数传递参数的 接口 例如 下面是两个必需的 IntArrayRC 构造函数 第 14 章与第 17 章将对构造函数作更 多的讲解 其中包括关于 为什么我们不需要提供 IntArrayRC 拷贝构造函数 的解释 inline IntArrayRC::IntArrayRC( int sz) : IntArray( sz ) {} inline IntArrayRC::IntArrayRC( const int *iar, int sz ) : IntArray( iar, sz ) {} 由冒号分割出来的部分称作成员初始化列表 member initialization list 它提供了一种 机制 通过这种机制 我们可以向 IntArray 的构造函数传递参数 两个 IntArrayRC 构造函数 的函数体都是空的 因为它们的工作就是把参数传递给相关的 IntArray 构造函数 我们无需 提供显式的 IntArrayRC 析构函数 因为派生类没有引入任何需要析构的数据成员 继承过来 的 需要析构的 IntArray 成员都由 IntArray 的析构函数来处理 头文件 IntArrayRC 上中含有 IntArrayRC 的定义 以及在类定义之外定义的全部内联成员 函数的定义 如果我们定义了非内联函数 则把它们放在 IntArrayRC.C——这个相关联的程 序文本文件中 下面这个小程序实现了 IntArray 与 IntArrayRC 两个类的层次结构 #include #include #include extern void swap(IntArray&,int,int); int main() { int array[ 4 ] = { 0, 1, 2, 3 }; IntArray ia1( array, 4 ); IntArrayRC ia2( array, 4 ); // 错误 一位偏移 off-by-one 应该是 size-1 // IntArray 对象捕捉不到这个错误 cout << "swap() with IntArray ia1\n"; swap( ia1, 1, ia1.size() ); // ok: IntArrayRC 对象可以捕捉到这样的错误 cout << "swap() with IntArrayRC ia2\n"; swap( ia2, 1, ia2.size() ); return 0; } 编译并执行这个程序 产生如下结果 swap() with IntArray ia1 swap() with IntArrayRC ia2 39 第二章 C++浏览 Assertion failed: index >= 0 && index < size C++支持另外两种形式的继承 多继承 multiple inheritance 也译多重继承 也就是 一个类可以从两个或多个基类派生而来 以及虚拟继承 virtual inheritance 在这种继承方 式下基类的单个实例在多个派生类之间共享 第 18 章将讨论这些内容 面向对象程序设计的 另一个较为深入的方面是 在程序执行过程中任意一个点上 我们都能够查询某类的引用或 指针所指向的实际类型 这是由 RTTI 运行时刻类型识别 设施提供的 我们将在 19.1 节 中讨论它 练习 2.8 一般来说 类型/子类型继承关系反映了一种 is-a 是一种 的关系 具有范围检查 功能的 ArrayRC 是一种 Array 一本书 Book 是一种图书外借资源 LibraryRentalMaterial 有声书 AudioBook 是一种书 Book 等等 下面哪些反映出这种 is-a 关系 (a)成员函数是一种(isA_kindOf)函数 (b)成员函数是一种类 (c)构造函数是一种成员函数 (d)飞机是一种交通工具 (e)摩托车是一种卡车 (f)圆形是一种几何图形 (g)正方形是一种矩形 (h)汽车是一种飞机 (i)借阅者是一种图书馆 练习 2.9 判断以下操作哪些可能是类型相关的 因此可把它们定义为虚拟函数 哪些可以在所有 类之间共享 对单个基类或派生类来说哪些是惟一的 (a) rotate(); (b) print(); (c) size(); (d) dateBorrowed(); (e) rewind(); (f) borrower(); (g) is_late(); (h) is_on_loan(); 练习 2.10 对于保护 protected 访问级别的使用已经有了一些争论 有人认为 使用保护访问级 别允许派生类直接访问基类的成员 这破坏了封装的概念 因此 所有的基类实现细节都应 该是私有的 private 另外一些人认为 如果派生类不能直接访问基类的成员 那么派生 类的实现将无法有足够的效率供用户使用 如果没有关键字 protected 类的设计者将被迫把 基类成员设置为 public 你怎样认为 练习 2.11 第二个争论是关于将成员函数显式地声明为 virtual 的必要性 一些人认为 这意味着如 40 第二章 C++浏览 果类的设计者没有意识到一个函数需要被声明为 virtual 则派生类的设计者就没有办法改写 这个关键函数 因此 他们建议把所有成员函数都设置为 virtual 的 另一方面 虚拟函数比 非虚拟函数的效率要低一些 6因为它们不能被内联 内联发生在编译时刻 而虚拟函数是 在运行时刻被处理的 所以它们可能是运行时刻效率低下的原因之一 尤其是小巧而又被 频繁调用的 与类型无关的函数 比如 Array 数组 的 size 函数 你又怎样认为呢 练习 2.12 下面的每个抽象类型都隐式地包含一族抽象子类型 例如 图书馆藏资料 LibraryRentalMaterial 抽象隐式地包含书 Book 音像 Puppet 视盘 Video 等 选择其中一个 找出该抽象的子类型层次 并为这个层次指定一个小的公有接口 且其中包 括构造函数 如果存在的话 指出哪些函数是虚拟的 并且写一小段伪代码程序来练习使用 这个公有接口 (a) Points (b) Employees (c) Shapes (d) TelephoneNumbers (e) BankAccounts (f) CourseOfferings 2.5 泛型设计 IntArray 类为预定义的整型数组类型提供了一个有用的替代类型 如果用户希望使用一 个 double 或 string 类型的数组 那该怎么办呢 实现一个 double 类型的数组与 IntArray 类的 区别只是在其所包含的元素的类型不同 而代码本身无需改变 C++的模板设施提供了一种机制 它能够将类成函数定义内部的类型和值参数化 parameterizing 我们要到 10.1 节才会讨论值参数 这些参数在其他方面不变的代码中 用作占位符 以后 这些参数会被绑定到实际类型上 可能是内置的类型 也可能是用户定 义的类型 例如 在 Array 类模板中 我们把数组所包含的元素的类型参数化 以后 当我 们实例化 instantiate 一个特定类型的实例时 如 int double 或 string 类型的 Array 数组 就可以在程序中直接使用这三个实例 就好像我们已经显式地为它们编写过代码一样 现在 来看一下 怎样把 IntArray 类转换成 Array 类模板 下面是定义 template < class elemType > class Array { public: // 把元素类型参数化 explicit Array( int size = DefaultArraySize ); Array( elemType *array, int array_size ); Array( const Array &rhs ); virtual ~Array() { delete [] ia; } bool operator==( const Array& ) const; bool operator!=( const Array& ) const; 6参见 LIPPMAN96a 其中讨论了虚拟函数性能的问题 41 第二章 C++浏览 Array& operator=( const Array& ); int size() const { return _size; } virtual elemType& operator[](int index){ return ia[index]; } virtual void sort(); virtual elemType min() const; virtual elemType max() const; virtual int find( const elemType &value ) const; protected: static const int DefaultArraySize = 12; int _size; elemType *ia; }; 关键字 template 引入模板 参数由一对尖括号 < > 括起来——本例中 有一个参数 elemType 关键字 class 表明这个参数代表一个类型 标识符 elemType 代表实际的参数名 它在 Array 类定义中出现了七次 都是作为实际类型的占位符 在 Array 类的每次实例化中 不论是实例化为 int double 或 string 等等 实例化的实际 类型都将代替 elemType 参数 下面的例子演示了怎样使用 Array 类模板 #include #include "Array.h" int main() { const int array_size = 4; // elemType 变成了 int Array ia(array_size); // elemType 变成了 double Array da(array_size); // elemType 变成了 char Array ca(array_size); int ix; for ( ix = 0; ix < array_size; ++ix ) { ia[ix] = ix; da[ix] = ix * 1.75; ca[ix] = ix + 'a'; } for ( ix = 0; ix < array_size; ++ix ) cout << "[ " << ix << " ] ia: " << ia[ix] << "\tca: " << ca[ix] << "\tda: " << da[ix] << endl; return 0; } 本例中 我们定义了三个独立的 Array 类模板的实例 42 第二章 C++浏览 Array ia(array_size); Array da(array_size); Array ca(array_size); 这些实例声明就是在类模板名的后面加上一对尖括号 然后在里面写上数组的实际类型 当我们定义类模板对象 如 ia da 或 ca 时 会发生什么事情呢 编译器必须为相关的对象 分配内存 为了做到这一点 形式模板参数被绑定到指定的实际参数类型上 对 ia 来说 Array 类模板通过将 elemType 绑定到类型 int 上 产生如下的类数据成员 // Array ia(array_size); int _size; int *ia; 结果是一个类 它与我们前面手工编码实现的 IntArray 类等价 对 da 来说 通过将 elemType 绑定到类型 double 上 成员变为 // Array da(array_size); int _size; double *ia; 类似地 对 ca 来说 通过将 elemType 绑定到类型 char 上 成员变为 // Array ca(array_size); int _size; char *ia; 类模板的成员函数会怎么样呢 不是所有的成员函数都能自动地随类模板的实例化而被 实例化 只有真正被程序使用到的成员函数才会被实例化 这一般发生在程序生成过程中的 一个独立阶段 16.8 节将详细讨论这个过程 编译并运行程序 会产主如下结果 [ 0 ] ia: 0 ca: a da: 0 [ 1 ] ia: 1 ca: b da: 1.75 [ 2 ] ia: 2 ca: c da: 3.5 [ 3 ] ia: 3 ca: d da: 5.25 模板机制也支持面向对象的程序设计 类模板可以作为基类或派生类 下面是一个带有 范围检查的 Array 类模板的定义 #include #include "Array.h" template class ArrayRC : public Array { public: ArrayRC( int sz = Array::DefaultArraySize ) : Array< elemType >( sz ){}; ArrayRC( elemType *ia, int sz ) : Array< elemType >( ia, sz ) {} ArrayRC( const ArrayRC &rhs ) : Array< elemType >( rhs ) {} virtual elemType& 43 第二章 C++浏览 operator[]( int index ) { assert( index >= 0 && index < Array::size() ); return ia[ index ]; } private: // ... }; 每个 ArrayRC 类的实例化过程都会实例化相应的 Array 类模板的实例 例如 下面的定 义 ArrayRC ia_rc( 10 ); 引起 Array 类和 ArrayRC 类的一个 int 实例被实例化 ia_rc 同上一节的非模板实例相同 为了说明这一点 我们重写前面的程序来练习 Array 和 ArrayRC 类模板类型 首先 为了支 持语句 // 现在 swap()必须也是一个模板 swap( ia1, 1, ia1.size() ); 我们必须将 swap()定义成一个函数模板 例如 #include "Array.h" template void swap( Array &array, int i, int j ) { elemType tmp = array[ i ]; array[ i ] = array[ j ]; array[ j ] = tmp; } 每个 swap()调用都会根据数组的类型产生适当的实例 下面是重新改写之后的 main()函 数 它使用了 Array 和 ArrayRC 类模板 #include #include "Array.h" #include "ArrayRC.h" template inline void swap( Array &array, int i, int j ) { elemType tmp = array[ i ]; array[ i ] = array[ j ]; array[ j ] = tmp; } int main() { Array ia1; ArrayRC ia2; cout << "swap() with Array ia1\n"; int size = ia1.size(); 44 第二章 C++浏览 swap( ia1, 1, size ); cout << "swap() with ArrayRC ia2\n"; size = ia2.size(); swap( ia2, 1, size ); return 0; } 程序的输出结果与非模板的 IntArray 类实现相同 练习 2.13 给出下列类型声明 template class Array; enum Status { ... }; typedef string *Pstring; 如果存在的话 下面哪些对象的定义是错误的 (a) Array< int*& > pri( 1024 ); (b) Array< Array > aai( 1024 ); (c) Array< complex< double > > acd( 1024 ); (d) Array< Status > as( 1024 ); (e) Array< Pstring > aps( 1024 ); 练习 2.14 重写下面的类定义 使它成为一个类模板 class example1 { public: example1( double min, double max ); example1( const double *array, int size ); double& operator[]( int index ); bool operator==( const example1& ) const; bool insert( const double*, int ); bool insert( double ); double min() const { return _min; }; double max() const { return _max; }; void min( double ); void max( double ); int count( double value ) const; private: int size; double *parray; double _min; double _max; }; 45 第二章 C++浏览 练习 2.15 给出如下的类模板 template class Example2 { public: explicit Example2( elemType val = 0 ) : _val( val ){} bool min( elemType value ) { return _val < value; } void value( elemType new_val ) { _val = new_val; } void print( ostream &os ) { os << _val; } private: elemType _val; }; template ostream& operator<< ( ostream &os, const Example2 &ex ) { ex.print( os ); return os; } 如下这样写会发生什么事情 (a) Example2< Array* > ex1; (b) ex1.min( &ex1 ); (c) Example2< int > sa( 1024 ), sb; (d) sa = sb; (e) Example2< string > exs( "Walden" ); (f) cout << "exs: " << exs << endl; 练习 2.16 在 Example2 的定义中 我们写 explicit Example2( elemType val = 0 ) : _val( val ){} 其意图是指定一个缺省值 以便用户可以写 Example2< Type > ex1( value ); Example2< Type > ex2; 但是 我们的实现把 Type 限制在一个 不能用 0 进行初始化的类型 的子集中 例如 用 0 初始化一个 string 类型 就是一个错误 7类似的情况是 如果 Type 不支持输出操作 符 那么 print()调用就会失败 因此 Example2 的输出操作符也会失败 如果 Type 不支 持小于操作符 那么 min()调用就会失败 C++语言本身并没有提供可以指示在实例化模板时 Type 有哪些隐含限制的方法 在最坏 的情况下 当程序编译失败时程序员才发现这些限制 你认为 C++语言应该支持限制 Type 的语法吗 如果你认为应该的话 请说明语法 并用它重写 Example2 的定义 如果认为不 需要 请说明理由 7 通常解决这个问题的做法是 Example2(elemType nval = elemType() ): _val(nval) {} 46 第二章 C++浏览 练习 2.17 在上一个练习中 我们说如果 Type 不支持输出操作符和小于操作符 那么对 print()和 min()的调用就会出错 在标准 C++中 错误的产生不是发生在类模板被创建的时候 而是在 print()与 min()被调用的时候 你认为这样的语义正确吗 是否应该在模板定义中标记这个错 误 为什么 2.6 基于异常的设计 异常 exception 是指在运行时刻程序出现的反情形 例如数组下标越界 打开文件失 败以及可用动态内存耗尽等等 程序员一般有自己的处理异常的风格 这导致了不同的编码 习惯 因而很难整合到一个单一的应用程序中 异常处理 exception handling 为 响应运行时刻的程序异常 提供了一个标准的语言 级的设施 它支持统一的语法和风格 也允许每个程序员进行微调 异常处理使得我们个需 要在程序中处处显式地测试异常状态 从而可以将测试异常状态的代码抽取出来 放入指定 的 显式标记的代码块中 因此异常处理设施大人地减少了程序代码的长度和复杂度 异常处理机制的主要构成如下 1 程序中异常出现的点 一旦识别出程序异常 就会导致抛出 raise 或 throw 异常 与异常被抛出时 正常的程序就被挂起 直到异常被处理完毕 在 C++中 异常的抛出由 throw 表达式来执行 例如 在下面的程序段中 一个 string 类型的异常被抛出来以便响应打开文 件失败异常 if ( ! infile ) { string errMsg( "unable to open file: " ); errMsg += fileName; throw errMsg; } 2 程序中异常被处理的点 典型地 程序异常的抛出与处理位于独立的函数或成员函数 调用中 找到处理代码通常要涉及到展开程序调用栈 Program call stack 一旦异常被处理 完毕 就恢复正常的程序执行 但不是在发生异常的地方恢复执行过程 而是在处理异常的 地方恢复执行过程 C++中 异常的处理由 catch 子句来执行 例如 下面的 catch 子句处理 在第 1 项中被抛出的异常 catch( string exceptionMsg ) { log_message( exceptionMsg ); return false; } catch 子句与 try 块相关联 一个 try 块用一个或多个 catch 子句将一条或多条语句组织起 来 例如 下面是函数 stats() int* stats( const int *ia, int size ) { int *pstats = new int[ 4 ]; 47 第二章 C++浏览 try { pstats[ 0 ] = sum_it( ia, size ); pstats[ 1 ] = min_val( ia, size ); pstats[ 2 ] = max_val( ia, size ); } catch( string exceptionMsg ) {/* 处理异常的代码 */} catch( const statsException &statsExcp ) {/* 处理异常的代码 */} pstats[ 3 ] = pstats[ 0 ]/size; do_something( pstats ); return pstats; } 在 stats()内部有 4 条语句在 try 块之外 在下面两条语句完成之前 可能会有异常被抛 出 (1) int *pstats = new int[ 4 ]; (2) do_something( pstats ); 在语句(1)中 new 表达式可能会失败 如果发生了这样的情况 标准库将产生 bad_alloc 标准异常 由于 bad_alloc 是在 try 块之外被抛出的 所以在 stats()中并没有试图要处理它 于是函数将终止 pstats 没有被初始化 stats()中后面的语句也不会被执行 异常机制承接了 控制权开一直保持直到异常处理完毕 在语句(2)中 在 do_something()中的语句 以及在 do_something()中被调用的语句 或 do_something()函数中被调用的函数所调用的语句等等 都可能会抛出异常 在从 do_something()调用开始的函数调用链返回之前 这个异常可能 也可能不 会被捕捉到 如 果异常被处理了 那么 stats()继续执行 就像什么也没有发生过一样 如果在 do_something() 结束之前 异常没有被处理 那么 stats()也会被终止 因为异常发生在 try 块之外 注意 如果 size 等于 0 那么 pstats[ 3 ] = pstats[ 0 ]/size; 将导致一个除以 0 的除法 尽管这将导致向 pstats[3]赋一个未定义的数据值 但是对于 除以 0 并没有标准异常被抛出 try块内的三条语句会怎么样呢 不同的行为区别如下 如果在 stats()里面 sum_it() min_val()及 max_val()终止之后 被抛出的异常是活动的 有效的 那么系统不是简单地终 止 stats() 而是顺序地检查与 try 块相关联的 catch 子句 试图处理被抛出来的异常 假设 sum_it()抛出如下异常 throw string( "internal error: adump27832" ); 则 pstats[0]不会被初始化 在 try 块中接下来的两条语句也不会被执行 异常机制意识到 sum_it()是在 try 块中被调用的 因而它将检查相关的两条 catch()子句 系统根据被抛出来的异常与 catch 子句中异常类型的匹配情况来选择 catch 子句 在本例 中 异常是 string 类型 与下面的 catch 子句相匹配 48 第二章 C++浏览 catch( string exceptionMsg ) {/* 处理异常的代码 */} 系统把控制传递给被选中的 catch 子句体 其中的语句将顺序执行 完成之后 除非在 处理该异常的子句中又抛出异常 否则控制将被传回到程序的当前点上 例如 如果我们已 经这样写 catch( string exceptionMsg ) { cerr << "stats(): exception occurred: " << exceptionMsg << endl; pstats[0] = pstats[1] = pstats[2] = 0; } 那么 在 catch 子句完成时 控制将被传递给 catch 子句集后面的可执行语句 本例中 语句 pstats[ 3 ] = pstats[ 0 ]/size; 被执行 然后是 do_something()调用 以及返回 pstats 而调用 stats()的函数根本不知道 曾经有异常被抛出 一段更为合理的异常处理代码可能如下所示 c atch( string exceptionMsg ) { cerr << "stats(): exception occurred: " << exceptionMsg << " unable to stat array " << endl; delete [] pstats; return 0; } 在上面的代码中 catch 子句直接把控制返回给外面的调用函数 我们希望外面的函数在 把返回值用作索引数组之前 先测试它是否为 0 如果 try 块内抛出的异常不能被相关联的 catch 子句处理 那么函数将被终止 然后 异 常机制再在调用 stats()的函数中查找处理代码 如果异常机制按照函数被调用的顺序回查每个函数直到 main()函数 仍然没有找到处理 代码 那么它将调用标准库函数 terminate() 缺省情况下 terminate()函数结束程序 一种特殊的 能够处理全部异常的 catch 子句如下 catch( ... ) { // 处理所有异常 虽然它无法 // 访问异常对象 } 我们可以把它看作是一种捕捉所有异常 catch-all 的 catch 子句 异常处理机制为统一地处理程序异常提供了语言一级的设施 第 11 章与 19 章将进一步 详细讨论 另一本配套的书 Inside the C++ Object Model LIPPMAN96a 中讨论了实现与 性能的话题 José e Lajoie 在 LIPPMAN96b 中的文章 Exception Handling: Behind the Scenses 对此也有讨论 LIPPMAN96b 中 Tom Cargill 的文章 Exception Handling: A False Sense of 49 第二章 C++浏览 Security 则对使用异常处理过程中易犯的错误做了很好的讨论 练习 2.18 下面的函数对可能的非法数据以及可能的操作失败完全没有提供检查 找出程序中所有 可能出错的地方 本练习中 我们不关心可能会抛出的异常 int *alloc_and_init( string file_name ) { ifstream infile( file_name ); int elem_cnt; infile >> elem_cnt; int *pi = allocate_array( elem_cnt ); int elem; int index = 0; while ( cin >> elem ) pi[ index++ ] = elem; sort_array( pi, elem_cnt ); register_data( pi ); return pi; } 练习 2.19 alloc_and_init()函数会调用到下面的函数 如果这些函数调用失败了 它们将抛出相应类 型的异常 allocate_array() noMem sort_array() int register_data() string 请在合适的地方插入一个或多个 try 块以及相应的 catch 子句来处理这些异常 在 catch 子句中只需简单地输出错误的出现情况 练习 2.20 检查在练习 2.18 中的函数 alloc_and_init()中所有可能出现的错误 指出哪些错误会抛出 异常 修改该函数 或用练习 2.18 的版本 或用练习 2.19 的版本 来抛出对被识别的异常 抛出文字串就可以了 2.7 用其他名字来命名数组 把代码分发给其他部门的诸多困难中 有一个是我们不知道全局名字会有什么样的影响 例如 在 Intel 公司 有人写了 class Array { ... }; 那么他就不能在相同的程序中既使用上面的 Array 类 又使用我们实现的那个 Array 类 50 第二章 C++浏览 名字的可视性使这两份实现代码相互排斥 在 C++标准化之前 解决这个问题的传统做法是在全局可见的名字前加上一个唯一的字 符串前缀 例如 我们可以这样发行数组 Array 类 class Cplusplus_Primer_Third_Edition_Array { ... }; 虽然这个名字可能是惟一的 我们不能保证这一点 但是写起来并不方便 标准 C++ 的名字空间机制是 C++语言针对这个问题提供的语言一级的解决方案 名字空间机制允许我们封装名字 否则这些名字就有可能会污染 影响 全局名字空间 pollute the global namespace 一般来说 只有当我们希望自己的代码被外部软件开发部 门使用时 才使用名字空间 例如 我们可以这样封装 Array 类 namespace Cplusplus_Primer_3E { template class Array { ... }; // ... } 关键字 namespace 后面的名字标识了一个名字空间 它独立于全局名字空间 我们可以 在里面放一些希望声明在函数或类之外的实体 名字空间井不改变其中的声明的意义 只是 改变了它们的可视性 在继续讨论之前 先扩展我们的可用名字空间集 namespace IBM_Canada_Laboratory { template class Array { ... }; class Matrix { ... }; // ... } namespace Disney_Feature_Animation { class Point { ... }; template class Array { ... }; // ... } 如果名字空间内的声明对程序而言不是立即可见的 那么我们怎样访问它们呢 我们可 以使用限定修饰名字符 qualified name notation 格式如下 namespace_identifier::entity_name; 如在 Cplusplus_Primer_3E::Array text; IBM_Canada_Laboratory::Matrix mat; Disney_Feature_Animation::Point origin( 5000, 5000 ); 虽然 Disney_Feature_Animation IBM_Canada_Laboratory 以及 Cplusplus_Primer_3E 都能 够唯一地标识相应的名字空间 但是 如果在程序中经常这样使用 则多少会有些麻烦 使 用名字空间标识符如 P3E DFA 或 IBM_CL 会更方便一些 但是它们表达的信息相对比较少 同时也增加了名字冲突的可能性 为了提供有意义的名字空间标识符 同时程序员又能很方 51 第二章 C++浏览 便地访问在名字空间内定义的实体 C++提供了别名设施 名字空间别名 namespace alias 允许用一个可替代的 短的或更一般的名字与一个现 有的名字空间关联起来 例如 // 提供一个更一般化的别名 namespace LIB = IBM_Canada_Laboratory; // 提供一个更短的别名 namespace DFA = Disney_Feature_Animation; 然后这个别名就可以用作原始名字空间的同义词 例如 #include "IBM_Canada.h" namespace LIB = IBM_Canada_Laboratory; int main() { LIB::Array ia(1024); // ... } 别名也可以用来封装正在使用的实际名字空间 例如 在此情形下 我们可以通过改变 分配给别名的名字空间 来改变所使用的声明集 而无需改变 通过别名访问这些声明 的 实际代码 例如 namespace LIB = Cplusplus_Primer_3E; int main() { // 在这种情况下 下面的声明无须改变 LIB::Array ia(1024); // ... } 但是 如果要让这项技术在实际工作中发挥作用 那么两个名字空间中的声明必须提供 同样的接口 例如 下面的代码就不能工作 因为 Disney 的 Array 类需要一个类型参数和一 个数组长度参数 namespace LIB = Disney_Feature_Animation; int main() { // 不再是一个有效的声明 LIB::Array ia(1024); // ... } 程序员常常希望在访问名字空间内声明的名字时不加限定修饰符 即使我们已经为名字 空间标识符提供了较短的别名 在每次访问该名字空间内声明的名字时也都要进行限定 还 是太麻烦 using 指示符 using directive 使名字空间内的所有声明都可见 这样这些声明 能够不加限定地使用 例如 #include "IBM_Canada_Laboratory.h" 52 第二章 C++浏览 // 使所有的名字都可见 using namespace IBM_Canada_Laboratory; int main() { // ok: IBM_Canada_Laboratory::Matrix Matrix mat( 4,4 ); // ok: IBM_Canada_Laboratory::Array Array ia( 1024 ); // ... } using与 namespace 都是关键字 被引用的名字空间必须已经被声明了 否则会引起编译 错误 using声明 using declaration 提供了选择更为精细的名字可视性机制 它允许使名字中 间中的单个声明可见 例如 #include "IBM_Canada_Laboratory.h" // 只让 Matrix 可见 using IBM_Canada_Laboratory::Matrix; int main() { // ok: IBM_Canada_Laboratory::Matrix Matrix mat(4,4); // error: IBM_Canada_Laboratory::Array not visible Array ia( 1024 ); // ... } 为了防止标准 C++库的组件污染用户程序的全局名字空间 所有标准 C++库的组件都声 明在一个被称为 std 的名字空间内 正如在第 1 章中提到的 即使我们在程序文本文件中包 含了 C++库头文件 头文件中声明的组件在我们的文本文件中也不是自动可见的 例如 在 标准 C++中 下面的代码实例就不能正常编译 #include // 错误 string 不是可见的 string current_chapter = "A Tour of C++"; 在头文件中的所有声明都包含在名字空间 std 中 正如第 1 章所提到的 我们可 以用 在#include 预处理器指示符后面加上 using 指示符 的办法 使 C++头文件中 的 在名字空间 std 中声明的组件对于我们的程序都是可见的 #include using namespace std; // ok: string 是可见的 string current_chapter = "A Tour of C++"; 为了使在名字空间 std 中声明的名字在我们的程序中可见 指示符 using 通常被看作是一 种比较差的选择方案 在上面的例子中 指示符 using 使头文件中声明的 并且位于 53 第二章 C++浏览 名字空间 std 中的所有组件在程序文本文件中都是可见的 这又将全局名字空间污染问题带 回来了 而这个问题正是 std 名字空间首先要努力避免的 它增加了 C++标准库组件的名字 与 我们程序中声明的全局名字 冲突的机会 现在 我们对名字空间机制已经有了一些了解 知道有另外两种机制可代替指示符 using 使我们能够引用到隐藏在名字空间 std 中的名字 string 我们可以使用限定的名字 例 如 #include // ok: 使用限定的名字 std::string current_chapter = "A Tour of C++"; 或如下使用 using 声明 #include using std::string; // ok: 上面的 using 声明使 string 可见 string current_chapter = "A Tour of C++"; 为了使用名字空间中声明的名字 建议使用带有精细选择功能的 using 声明代替 using 指 示符 这也正是本书的代码示例中没有出现 using 指示符的另一个原因 理想情况下 每一 个代码示例对它所用到的每个库组件都应该有一个 using 声明 为了限制例子代码的长度 也因为本书的许多例子是在不支持名字空间的情况下被编译的 所以 using 声明就没有显示 出来 8.6 节将进一步讨论怎样对标准 C++库的组件使用 using 声明 在接下来的四章中 我们将讲述另外四个类的设计 第 3 章讲的是 String 字符串 类 的设计与实现 第 4 章介绍整数 Stack 栈 类的设计 第 5 章是 List 列表 类 第 6 章对 第 4 章定义的 Stack 栈 类进行重新设计 名字空间机制允许我们把每个类放在单独的头文 件中 但是仍然能把它们的名字封装到单个 Cplusplus_Primer_3E 名字空间中 在第 8 章中 我们将讨论这项技术 并对名字空间作更多的介绍 练习 2.21 给出如下名字空间定义 namespace Exercise { template class Array { ... }; template void print( Array< Etype > ); class String { ... }; template class List { ... }; } 以及下面的程序 int main() { const int size = 1024; Array< String > as( size ) 54 第二章 C++浏览 List< int > il( size ); // ... Array< String > *pas = new Array(as); List *pil = new List(il); print( *pas ); } 同为类型名被封装在名字空间中 所以当前程序编译失败 把程序修改为 1 用限定名字修饰符来访问名字空间 Exercise 中的类型定义 2 使用 using 声明来访问类型定义 3 用名字空间别名机制 4 用 using 指示符 2.8 标准数组——向量 正如我们已经看到的 尽管 C++内置的数组支持容器的机制 但是它不支持容器抽象的 语义 为了在这样的层次上编写程序 在标准 C++之前 我们要么从某个途径获得这样的类 要么自己实现这样的类 在标准 C++中 数组类是 C++标准库的一部分 现在它不叫数组 而叫向量 vector 了 当然 向量是一个类模板 所以我们这样写 vector ivec( 10 ); vector svec( 10 ); 上面的代码分别定义了一个包含 10 个整型对象的向量和一个包含 10 个字符串对象的向 量 在我们实现的 Array 类模板与 vector 类模板的实现之间有两个主要区别 第一个区别是 vector 类模板支持 向现有的数组元素赋值 的概念以及 插入附加元素 的概念——即 vector 数组可以在运行时刻动态增长 如果程序员希望使用这个特性的话 第二个区别是更加广 泛 代表了设计方法的重要转变 vector 类不是提供一个巨大的 可以适用于向量 的成员 操作集 如 sort() min() max()及 find()等等 而是只提供了一个最小集 如等于 小于操 作符 size() empty()等操作 而一些通用的操作如 sort() min() max()和 find()等等 则是 作为独立的泛型算法 generic algorithm 被提供的 要定义一个向量 我们必须包含相关的头文件 #include < vector > 下面都是 vector 对象的合法定义 #include < vector > // 创建 vector 对象的各种方法 vector veco; // 空的 vector 55 第二章 C++浏览 const int size = 8; const int value = 1024; // size 为 8 的 vector // 每个元素都被初始化为 0 vector vec1( size ); // size 为 8 的 vector // 每个元素都被动始化为 1024 vector vec2( size, value ); // vtc3 的 size 为 4 // 被初始化为 ia 的 4 个值 int ia[4] = { 0, 1, 1, 2 }; vector vec3( ia, ia+4 ); // vec4 是 vec2 的拷贝 vector vec4( vec2 ); 既然定义了向量 我们就需要遍历里边的元素 与 Array 类模板一样 标准 vector 类模 板也支持使用下标操作符 例如 #include extern int getSize(); void mumble() { int size = getSize(); vector< int > vec( size ); for ( int ix = 0; ix < size; ++ix ) vec[ ix ] = ix; // ... } 另外一种遍历方法是使用迭代器对 iterator pair 来标记向量的起始处和结束处 迭代 器是一个支持指针类型抽象的类对象 vector 类模板提供了一对操作 begin()和 end() 它们分 别返回指向 向量开始处 和 结束处后 1 个 的迭代器 这一对迭代器合起来可以标记出 待遍历元素的范围 例如 下面的代码是前面代码段的一个等价实现 #include < vector > extern int getSize(); void mumble() { int size = getSize(); vector< int > vec( size ); vector< int >::iterator iter = vec.begin(); for ( int ix = 0; iter != vec.end(); ++iter, ++ix ) *iter = ix; 56 第二章 C++浏览 // ... } iter的定义 vector< int >::iterator iter = vec.begin(); 将其初始值指向 vec 的第一个元素 iterator 是 vector 类模板中用 typedef 定义的类型 而这里的 vector 类实例包含 int 类型的元素 下面的代码使迭代器指向 vector 的下一个元 素 ++iter 下面的代码解除迭代器的引用 以便访问实际的元素 *iter 能够应用到向量上的操作惊人地多 但是它们并不是作为 vector 类模板的成员函数提供 的 它们是以一个独立的泛型算法集的形式 由标准库提供 下面是一组可供使用的泛型算 法的示例 搜索 search 算法 find() find_if() search() binary_search() count()和 count_if() 分类排序 sorting 与通用排序 ordering 算法 sort() partial_sort() merge() partition() rotate() reverse()和 random_shuffle() 删除 deletion 算法 unique()和 remove() 算术 numeric 算法 accumulate() partial_sum() inner_product()和 adjacent_ difference() 生成 generation 和变异 mutation 算法 generate() fill() transformation() copy()和 for_each() 关系 Relational 算法 equal() min()和 max() 泛型算法接受一对迭代器 它们标记了要遍历元素的范围 例如 ivec 是一个包含某 种类型元素的 某个长度的向量 要用 sort()对它的全部元素进行排序 我们只需简单地这 样写 sort( ivec.begin(), ivec.end() ); 只想对 ivec 向量的前面一半进行排序 可以这样写 sort( ivec.begin(), ivec.begin()+ivec.size()/2 ); 泛型算法还能接受指向内置数组的指针对 例如 已知数组 int ia[7] = { 10, 7, 9, 5, 3, 7, 1 }; 我们可以如下对整个数组排序 sort( ia, ia+7 ); 我们还可以只对前四个元素排序 sort( ia, ia+4 ); 要使用这些算法 我们必须包含与它们相关的头文件 #include 57 第二章 C++浏览 下面的代码显示了怎样把各种各样的泛型算法应用到 vector 类对象上 #include #include #include int ia[ 10 ] = { 51, 23, 7, 88, 41, 98, 12, 103, 37, 6 }; int main() { vector< int > vec( ia, ia+10 ); // 排序数组 sort( vec.begin(), vec.end() ); // 获取值 int search_value; cin >> search_value; // 搜索元素 vector::iterator found; found = find( vec.begin(), vec.end(), search_value ); if ( found != vec.end() ) cout << "search_value found!\n"; else cout << "search_value not found!\n"; // 反转数组 reverse( vec.begin(), vec.end() ); // ... } 标准库还提供了对 map 关联数组的支持 即数组元素可以被整数值之外的其他东西索引 例如 我们可以这样来支持一个电话目录 这个电话目录是电话号码的数组 但是它的元素 可以由该号码所属人的姓名来索引 #include #include #include "TelephoneNumber.h" map< string, telephoneNum > telephone_directory; 在第 6 章我们将看到 vector map 以及标准 C++库支持的其他容器类型 本书将通过一 个文本查询系统的实现来说明这些类型的用法 而第 12 章会讲解泛型算法 附录按字母顺序 提供了每个算法的解释及其用法 本章大致地讲述了 C++为数据抽象 基于对象的程序设计 面向对象的程序设计 泛 型程序设计 模板 容器类型以及泛型算法 大型程序设计 异常处理与名字空间 提供 的支持 而本书余下的部分将更详细地介绍这些内容 逐步讲解 C++中基本 但又非常先进 的特性 58 第二章 C++浏览 练习 2.22 解释每个 vector 定义的结果 string pals[] = { "pooh", "tigger", "piglet", "eeyore", "kanga" }; (a) vector svec1( pals, pals+5 ); (b) vector ivec1( 10 ); (c) vector ivec2( 10, 10 ); (d) vector svec2( svec1 ); (e) vector dvec; 练习 2.23 已知下列函数声明 请实现 min()的函数体 它查找并返回 vec 的最小元素 要求首先使 用 索引 vec 中元素的 for 循环 来实现 min() 然后 再使用 通过迭代器遍历 vec 的 for 循环 来实现 min() template elemType min( const vector &vec ); 第二篇 基本语言 我们编写的程序以及所保存的程序数据在计算机的内存中是以二进制位序列的方式存放 的 位 bit 是含有 0 或 1 值的一个单元 在物理上它的值是个负或正电荷 典型的计算机 内存段如下所示 00011011011100010110010000111011 ... 在这个层次上 位的集合没有结构 很难以某种意义来解释这些位序列 但是 偶然情 况下 尤其是当我们访问实际的机器硬件时 我们会因为需要或者为了方便在单独的位或 者位集合的层次上编写程序 C++语言提供了一套位操作符以支持位操作 以及一个位集合 bitset 容器类型 可以用来声明含有位集合的对象 第 4 章将讨论这些操作符以及位集合 容器类型 为了能够从整体上考虑这些位 我们给位序列强加上结构的概念 这样的结构被称作字 节 byte 和字 word 通常 一个字节由 8 位构成 而一个字由 32 位构成 或者说是 4 个字节 但是 工作站操作系统现在正在朝 64 位系统的方向转换 不同计算机中的字的大 小也不尽相同 我们说这个值是依赖于机器的 machine-dependent 或者说机器相关的 下面的这张图说明了位流怎样被组织成四个对寻址的字节行 1024 0 0 0 1 1 0 1 1 1032 0 1 1 1 0 0 0 1 1040 0 1 1 0 0 1 0 0 1048 0 0 1 1 1 0 1 1 通过对内存进行组织 我们可以引用特定的位集合 因此 我们可以说 在地址 1024 上的字 或者 在地址 1040 上的字节 例如 我们可以这样说 在地址 1032 上的字节不 等于在地址 1048 上的字节 60 第二篇 基本语言 但是 我们仍然不能讲出在地址 1032 处的内容的意义 为什么呢 因为不知道怎样解释 这些位序列 为了说明在地址 1032 上的字节的意义 我们必须知道这些值代表的类型 类型抽象使我们能够对一个定长的位序列进行有意义的解释 C++提供了一组预定义的 数据类型 如字符型 整型 浮点型 以及一组基本的数据抽象 如 string vector 和复数 它还提供了一组操作符 或称运算符 如加 减 等于 小于操作符等来操纵这些类型 C++还为程序流控制提供了为数不多的一组语句 如 while 循环和 if 语句 这些要素构成了 一个符号系统 人们已经用它写出了许多大型的 复杂的实用系统 掌握 C++的第一步就是 要理解这些基本的组件 这是本书第二篇的主题 第 3 章将概括说明预定义的和扩展的数据类型 并讲解构造新数据类型的机制 主要是 在 2.3 节中介绍的类机制 第 4 章将集中讨论对表达式的支持 并讲解预定义操作符 类型 转换 操作符优先级以及相关的问题 程序的最小独立单元是语句 这是第 5 章的主题 第 6 章将介绍标准库容器类型 比如 vector 和 map 我们将通过一个文本查询系统的实现说明 它们的用法 3 C++数据类型 本章将概括介绍 C++中预定义的内置的 built in 或称基本的 primitive 数据 类型 本章将以文字常量 literal constant 开始 如 3.14159 和 pi 然后介 绍符号变量 symbolic variable 和对象 object 的概念 C++程序中的对象必 须被定义为某种特定的类型 本章的余下部分将介绍可以用来声明对象的各种类 型 另外 我们还将把 C++内置的对字符串与数组的支持与 C++标准库提供的类 抽象进行对比 虽然标准库中的抽象类型不是基本类型 但是它们也是使用 C++ 程序的基础 我们希望尽早地介绍它们 以此来鼓励和说明它们的使用 我们把这 些类型看作是基本内置类型和基本类抽象类型的扩展基础语言. 3.1 文字常量 C++预定义了一组数值数据类型 可以用来表示整数 浮点数和单个字符 此外 还预 定义了用来表示字符串的字符数组 字符型 char 通常用来表示单个字符和小整数 它可以用一个机器字节来表示 整型 int 短整型 short 长整型 long 它们分别代表不同长度的整数值 典型情况 下 short 以半个字表示 int 以一个机器字表示 而 long 为一个或两个机器字 在 32位机器中 int 和 long 通常长度相同 浮点型 float 双精度 double 和长双精度 long double 分别表示单精度浮点数 双 精度浮点数和扩展精度的浮点数值 典型情况下 float 为一个字 double 是两个字 long double为三个或四个字 char short int 和 long 称为整值类型 integral type 整值类型可以有符号 也可以无 符号 在有符号类型中 最左边的位是符号位 余下的位代表数值 在无符号类型中 所有 的位都表示数值 如果符号位被置为 1 数值被解释成负数 如果是 0 则为正数 一个 8 位有符号的 char 可以代表从-128 到 127 的数值 而一个无符号的 char 则表示 0 到 255 范围 内的数值 当一个数值 例如 1 出现在程序中时 它被称为文字常量 literal constant 称之为 文字 是因为我们只能以它的值的形式指代它 称之为 常量 是因为它的值不能被改变 62 第三章 C++数据类型 每个文字都有相应的类型 例如 0 是 int 型 而 3.14159 是 double 型的文字常量 文字常量 是不可寻址的 nonaddressable 尽管它的值也存储在机器内存的某个地方 但是我们没有 办法访问它们的地址 整数文字常量可以被写成十进制 八进制或者十六进制的形式 这不会改变该整数值的 位序列 例如 20 可以写成下面三种形式中的任意一种 20 // 十进制 024 // 八进制 0x14 // 十六进制 在整型文字常量前面加一个 0 该值将被解释成一个八进制数 而在前面加一个 0x 或 0X 则会使一个整型文字常量被解释成十六进制数 第 20 章 输入/输出流库 将讨论八进制 或十六进制形式的输出值 在缺省情况下 整型文字常量被当作是一个 int 型的有符号值 我们可以在文字常量后 面加一个 L 或 l 字母 L 的大写形式或者小写形式 将其指定为 long 类型 一般情 况下 我们应该避免使用小写字母 因为它很容易被误当作数字 1 类似地 我们可以在整 型文字常量的后面加上 u 或 U 将其指定为一个无符号数 此外 我们还可以指定无 符号 long 型的文字常量 例如 128u 1024UL 1L 8Lu 浮点型文字常量可以被写成科学计数法形式或普通的十进制形式 使用科学计数法 指 数可写作 e 或 E 浮点型文字常量在缺省情况下被认为是 double 型 单精度文字常量 由值后面的 f 或 F 来标示 类似地 扩展精度中值后面跟的 l 或 L 来指示 注 意 f F l L 后缀只能用在十进制形式中 例如 3.14159F 0.1f 12.345L 0.0 3e1 1.0E-3 2. 1.0L 单词 true 和 false 是 bool 型的文字常量 例如 可以这样写 true false 可打印的文字字符常量可以写成用单引号括起来的形式 例如 'a' '2' ',' ' ' (空格) 一部分不可打印的字符 单引号 双引号以及反斜杠可以用如下的转义序列来表示 转 义序列以反斜杠开头 newline(换行符) \n horizontal tab(水平制表键) \t vertical tab(垂直制表键) \v backspace(退格键) \b carriage return (回车键) \r formfeed (进纸键) \f alert (beel) (响铃符) \a backslash (反斜杠键) \\ question mark (问号) \? single quote (单引号) \' double quote (双引号) \" 63 第三章 C++数据类型 一般的转义序列采用如下格式 \ooo 这里的 ooo 代表三个八进制数字组成的序列 八进制序列的值代表该字符在机器字符集 里的数字值 下面的示例使用 ASCII 码字符集表示文字常量 \7 (bell) \14 (newline) \0 (null) \062 ('2') 另外 字符文字前面可以加 L 例如 L'a' 这称为宽字符文字 类型为 wchar_t 宽字符常量用来支持某些语言的字符集合 如汉语 日语 这些语言中的某些字符不能用单个字符来表示 字符串文字常量由零个或多个用双引号括起来的字符组成 不可打印字符可以由相应的 转义序列来表示 而一个字符串文字可以扩展到多行 在一行的最后加上一个反斜杠 表明 字符串文字在下一行继续 例如 "" (空字符串) "a" "\nCC\toptions\tfile.[cC]\n" "a multi-line \ string literal signals its \ continuation with a backslash" 字符串文字的类型是常量字符数组 它由字符串文字本身以及编译器加上的表示结束的 空 null 字符构成 例如 'A' 代表单个字符 A 下面则表示单个字符 A 后面跟一个空字符 "A " 空字符是 C 和 C++用来标记字符串结束的符号 正如存在宽字符文字 比如 L'a' 同样地 也有宽字符串文字 它仍然以 L 开头 如 L"a wide string literal" 宽字符串文字的类型是常量宽字符的数组 它也有一个等价的宽空字符作为结束标志 如果两个字符串或宽字符串在程序中相邻 C++就会把它们连接在一起 并在最后加上 一个空字符 例如 "two" "some" 它的输出结果是 twosome 如果将一个字符串常量与一个宽字符串常量连接起来 会发生什么后果 例如 // 不建议这样使用 "two" L"some" 64 第三章 C++数据类型 结果是未定义的 undefined ——即 没有为这两种不同类型的连接定义标准行为 使 用未定义行为的程序被称作是不可移植的 虽然程序可能在当前编译器下能正确执行 但是 不能保证相同的程序在不同的编译器 或当前编译器的以后版本下编译后 仍然能够正确执 行 在本来能够运行的程序中跟踪这类问题是一件很令人不快的任务 因此 建议不要使用 未定义的程序特性 我们会在合适的时候指出这样的特性 练习 3.1 说明下列文字常量的区别 (a) 'a', L'a', "a", L"a" (b) 10, 10u, 10L, 10uL, 012, 0xC (c) 3.14, 3.14f, 3.14L 练习 3.2 下列语句哪些是非法的 (a) "Who goes with F\144rgus?\014" (b) 3.14e1L (c) "two" L"some" (d) 1024f (e) 3.14UL (f) "multiple line comment" 3.2 变量 假设有这样一个问题 计算 2 的 10 次方 我们首先想到的可能是 #i nclude int main() { // 第一个解决方案 cout << "2 raised to the power of 10: "; cout << 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2; cout << endl; return 0; } 这样确实能够解决问题 但是 可能需要检查两到三遍 以确保正好有 10 个常数 2 参与 乘法 这个程序产生正确的答案 1024 接着 我们被要求算出 2 的 17 次方和 2 的 23 次方 每次都要修改程序确实很麻烦 但 更糟糕的是 这样做经常会出错 修改后的程序常常会多乘或少乘了一个 2 最后我们又被 要求生成 2 的从 0 到 15 次方的数值的表 使用文字常量需要与 32 行类似下面的格式 cout << "2 raised to the power of X\t"; cout << 2 * ... * 2; 这里 X 随每对语句递增 1 65 第三章 C++数据类型 从某种角度来看 这样确实完成了任务 我们的老板不可能去看我们具体的做法 只要 我们的结果正确并且及时就可以啦 实际上 在许多实际开发环境中 成功的主要评价标准 是最后的结果 至于对处理过程的讨论则很可能被视为学究气 不切实际 总是得不到重视 虽然这种蛮力型的方案也能解决问题 但是它总让人感到不快 而且有些危机感 这种 方案吸引人的地方就是简单 我们明白需要做什么 虽然它常常很乏味 复杂的技术方案一 般在开始阶段需要很多时间 这时常常会感觉什么都没有做 而且因为处理过程是自动的 所以就更有可能出错 事情不可避免会出错 但好处在于 这些错误过程中 不但事情很快能完成 而且拓展 了想像的空间 有时候 这个过程也挺有趣的 在本例中 用来取代这种蛮力型的方案包括两部分内容 使用有名字的对象来读写每步 的计算 引入一个控制流结构 以便在某个条件为真时 可以重复执行一系列语句 下面是 一种 技术先进的 计算 2 的 10 次幂的程序 #include int main() { // int 类型的对象 int value = 2; int pow = 10; cout << value << " raised to the power of " << pow << ": \t"; int res = 1; // 保存结果 // 循环控制语句: 反复计算 res // 直至 cnt 大于 pow for ( int cnt=1; cnt <= pow; ++cnt) res = res * value; cout << res << endl; } value pow res 以及 cnt 是变量 它们允许对数值进行存储 修改和查询 for 循环使计 算过程重复执行 pow 次 虽然这种层次的通用化能使程序更加灵活 但是这样的程序仍然是不可重用的 我们必 须进一步通用化 把计算指数值的那部分程序代码抽取出来 定义成一个独立的函数 以使 其他函数能够凋用它 例如 int pow( int val, int exp ) { for ( int res = 1; exp > u; --exp ) res = res * val; return res; } 现在 每个需要计算指数值的程序 都可以使用 pow()的实例 而不是重新实现它 我 66 第三章 C++数据类型 们可以用如下的代码来生成 2 的幂的表 #include extern int pow(int, int); int main() { int val = 2; int exp = 15; cout << "The Powers of 2\n"; for ( int cnt=0; cnt <= exp; ++cnt ) cout << cnt << ": " << pow(val, cnt) << endl; return 0; } 实际上 这个 pow()的实现既不够健壮也不够通用 例如 如果指数是负数 该怎么办 如果是 1 000 000 呢 对于负数指数 我们的程序总是返回 1 对于一个非常大的指数 变量 int res 又小得不能够容纳这个结果 因此 对于一个大的指数将返回一个任意的 不正确 的值 在这种情况下 最好的解决方案是将返回值的类型修改为 double 类型 从通用的 角度来说 我们的程序应该能够处理整数和浮点数类型的底数和指数 甚至其他的类型 正 如你所看到的 为一个未知的用户组写一个健壮的通用函数 比 实现一个特定的算法来解 决眼前的问题 要复杂得多 pow()的实际实现代码见 PLAUGER92 3.2.1 什么是变量 变量为我们提供了一个有名字的内存存储区 可以通过程序对其进行读 写和处理 C++ 中的每个符号变量都与一个特定的数据类型相关联 这个类型决定了相关内存的大小 布局 能够存储在该内存区的值的范围以及可以应用其上的操作集 我们也可以把变量说成对象 object 下面是 5 个不同类型的变量定义 在后面我们会介绍变量定义的细节情况 int student_count; double salary; bool on_loan; string street_address; char delimiter; 变量和文字常量都有存储区 并且有相关的类型 区别在于变量是可寻址的 addressable 对于每一个变量 都有两个值与其相关联 1.它的数据值 存储在某个内存地址中 有时这个值也被称为对象的右值 rvalue 读 做 are-value 我们也可认为右值的意思是被读取的值 read value 文字常量和变量都可 被用作右值 2.它的地址值——即 存储数据值的那块内存的地址 它有时被称为变量的左值 lvalue 读作 ell-value 我们也可认为左值的意思是位置值 location value 文字常量不能被用作 左值 在下面的表达式中 67 第三章 C++数据类型 ch = ch - 'O'; 变量 ch 同时出现在赋值操作符的左边和右边 右边的实例被读取 与其相关联的内存中 的数据值被读出 左边的 ch 用作写入 减操作的结果被存储在 ch 的位置值所指向的内存区 中 原来的数据值会被覆盖 在表达式的右边 ch 和文字字符常量用作右值 在左边 ch 用作左值 一般地 赋值操作符的左边总是要求一个左值 例如 下列的写法将产生编译错误 // 编译错误: 等号左边不是一个左值 // 错误: 文字常量不是一个左值 0 = 1; // 错误: 算术表达式不是一个左值 salary + salary * 0.10 = new_salary; 在本书中 我们将会看到许多 左值和右值的用法会影响程序的语义行为和性能 的情 况——尤其在 向函数传递值 或者 从函数返回值 的时候 变量的定义会引起相关内存的分配 因为一个对象只能有一个位置 所以程序中的每个 对象只能被定义一次 如果在一个文件中定义的对象需要在另一个文件中被访问 就可能会 出现问题 例如 // file module0.C // 定义 fileName 对象 string fileName; // ... 为 fileName 赋一个值 // file module1.C // 需要使用 fileName 对象 // 喔: 编译失败: // 在 module1.C 中 fileName 未定义 ifstream input_file( fileName ); 在 C++中 程序在使用对象之前必须先知道该对象 这对 编译器保证对象在使用时的 类型正确性 是必需的 引用一个未知的对象将引起编译错误 在本例中 由于在 model1.C 中没有定义 fileName 所以该文件编译失败 要编译 model1.C 必须让程序知道 fileName 但又不能引入第二个定义 我们可以通过 声明 declaring 该变量来做到这一点 // file module1.C // 需经使用 fileName 对象 // 声明 fileName, 也即, 让程序知道它, // 但又不引入第二个定义 extern string fileName; ifstream input_file( fileName ); 对象声明 declaration 的作用是使程序知道该对象的类型和名字 它由关键字 extern 以及跟在后面的对象类型以及对象的名字构成 关于 extern 的全面介绍见 8.2 节 声明 68 第三章 C++数据类型 不是定义 不会引起内存分配 实际上 它只是说明了在程序之外的某处有这个变量的定义 虽然一个程序只能包含一个对象的一个定义 但它可以包含任意数目的对象声明 比较 好的做法 不是在每个使用对象的文件中都提供一个单独的声明 而是在一个头文件中声明 这个对象 然后再在需要声明该对象的时候包含这个头文件 按照这种做法 如果需要修改 对象的声明 则只需要修改一次 就能维持多个使用该对象的文件中声明的一致性 8.2 节将对头文件有更多的说明 3.2.2 变量名 变量名 即变量的标识符 identifier 可以由字母 数字以及下划线字符组成 它必 须以字母或下划线开头 并且区分大写字母和小写字母 语言本身对变量名的长度没有限制 但是为用户着想 它不应该过长 下面这个变量名虽然合法 但是太长了 gosh_this_is_an_impossibly_long_name_to_type C++保留了一些词用作关键字 关键字标识符不能再作为程序的标识符使用 我们已经 见到过 C++语言的许多关键字 表 3.1 列出了 C++关键字全集 表 3.1 C++关键字 asm auto bool break case catch char class const const_cast Continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while 对于命名对象有许多已普遍接受的习惯 主要考虑因素是程序的可读性 对象名一般用小写字母 例如 我们往往写成 index 而不写 INDEX 一般把 Index 当作类型名 而 INDEX 则一般被看作常量值 通常用预处理器指示符#define 定义 标识符一般使用助记的名字——即 能够对程序中的用法提供提示的名字 如 on_loan或 salary 至于是应写成 table 还是 tbl 这纯粹是风格问题 不是正确性的 问题 69 第三章 C++数据类型 对于多个词构成的标识符 习惯上 一般在每个词之间加一个下划线 或内嵌的每 个词第一个字母大写 例如 一般会写成 student_loan 或 studentLoan 而不是 studentloan 我在这里已经列出了所有三种形式 一般有面向对象背景的人 ObjectOrientedBackground 喜欢用大写字母 而有 C 或过程化背景的人 C_or_procedural_background 则喜欢下划线 再次说明 使用 isa isA 或 is_a 只是个风格问题 与正确与否无关 3.2.3 对象的定义 一个简单的对象定义由一个类型指示符后面跟一个名字构成 以分号结束 例如 double salary; double wage; int month; int day; int year; unsigned long distance; 当同类型的多个标识符被定义的时候 我们可以在类型指示符后面跟一个由逗号分开的 标识符列表 这个列表可跨越多行 最后以分号结束 例如 上面的定义可写成 double salary, wage; int month, day, year; unsigned long distance; 一个简单的定义指定了变量的类型和标识符 它并不提供初始值 如果一个变量是在全 局域 global scope 内定义的 那么系统会保证给它提供初始值 0 在本例中 salary wage month day year 以及 distance 都被初始化为 0 因为它们都是在全局域内定义的 如果变 量是在局部域 local scope 内定义的 或是通过 new 表达式动态分配的 则系统不会向它 提供初始值 0 这些对象被称为是未初始化的 uninitialized 未初始化的对象不是没有值 而是它的值是未定义的 undefined 与它相关联的内存区中含有一个随机的位串 可能 是以前使用的结果 因为使用未初始化对象是个常见错误 而且很难发现 所以 一般建议为每个被定义的 对象提供一个初始值 在有些情况下 这不是必须的 然而 在你能够识别这些情况之前 为每个对象提供初始值是个安全的作法 类机制通过所谓的缺省构造函数 2.3 节已经介 绍过 提供了类对象的自动初始化 我们将在本章后面部分关于标准库 string 和复数类型 3.11 节和 3.15 节 的讨论中看到这一点 现在 请注意以下代码 int main() { // 未初始化的局部对象 int ival; // 通过 string 的缺省构造函数进行初始化 string project; // ... } 70 第三章 C++数据类型 ival是一个未初始化的局部变量 但 project 是一个已经初始化的类对象——被缺省的 string 类构造函数自动初始化 初始的第一个值可以在对象的定义中指定 一个被声明了初始值的对象也被称为已经初 始化的 initialized C++支持两种形式的初始化 第一种形式是使用赋值操作符的显式语 法形式 int ival = 1024; string project = "Fantasia 2000"; 在隐式形式中 初始值被放在括号中 int ival( 1024 ); string project( "Fantasia 2001" ); 在这两种情况中 ival 都被初始化为 1024 而 project 的初始值为 Fantasia 2000 逗号分隔的标识符列表同样也能为每个对象提供显式的初始值 语法形式如下 double salary = 9999.99, wage = salary + 0.01; int month = 08; day = 07, year = 1955; 在对象的定义中 当对象的标识符在定义中出现后 对象名马上就是可见的 因此用对 象初始化它自己是合法的 只是这样做不太明智 例如 // 合法, 但不明智 int bizarre = bizarre; 另外 每种内置数据类型都支持一种特殊的构造函数语法 可将对象初始化为 0 例如 // 设置 ival 为 0 dval 为 0.0 int ival = int(); double dval = double(); 下列定义中 // int() applied to each of the 10 elements vector< int > ivec( 10 ); 函数 int()被自动应用在 ivec 包含的 10 个元素上 2.8 节介绍了 vector 3.6 节与第 6 章将有详细讨论 对象可以用任意复杂的表达式来初始化 包括函数的返回值 例如 #include #include double price = 109.99, discount = 0.16; double sale_price( price * discount ); string pet( "wrinkles" ); extern int get_value(); int val = get_value(); unsigned abs_val = abs( val ); abs()是标准 C 数学库中预定义的函数 它返回其参数的绝对值 get_value()是一个用户 71 第三章 C++数据类型 定义的函数 它返回一个随机整数值 练习 3.3 下列定义哪些是非法的 请改正之 (a) int car = 1024, auto = 2048; (b) int ival = ival; (c) int ival( int() ); (d) double salary = wage = 9999.99; (e) cin >> int input_value; 练习 3.4 区分左值与右值 并给出它们的例子 练习 3.5 说明下列 student 和 name 两个实例的区别 (a) extern string name; string name( "exercise 3.5a" ); (b) extern vector students; vector students; 练习 3.6 下列名字哪些是非法的 请改正之 (a) int double = 3.14159; (b) vector< int > _; (c) string namespace; (d) string catch-22; (e) char 1_or_2 = '1'; (f) float Float = 3.14f; 练习 3.7 下面的全局对象定义和局部对象定义有什么区别(如果你认为有区别的话) string global_class; int global_int; int main() { int local_int; string local_class; // ... } 3.3 指针类型 在 2.2 节中 我们简要地介绍了指针和动态内存分配 指针持有另一个对象的地址 使我们能够间接地操作这个对象 指针的典型用法是构建一个链接的数据结构 例如树 tree 72 第三章 C++数据类型 和链表 list 并管理在程序执行过程中动态分配的对象 以及作为函数参数类型 主要用 来传递数组或大型的类对象 每个指针都有一个相关的类型 不同数据类型的指针之间的区别不是在指针的表示上 也不在指针所持有的值 地址 上——对所有类型的指针这两方面都是相同的 8不同之处在 于指针所指的对象的类型上 指针的类型可以指示编译器怎样解释特定地址上内存的内容 以及该内存区域应该跨越多少内存单元 如果一个 int 型的指针寻址到 1000 内存处 那么在 32 位机器上 跨越的地址空间 是 1000~1003 如果一个 double 型的指针寻址到 1000 内存处 那么在 32 位机器上 跨越的地址 空间是 1000~1007 下面是指针定义的例子 int *ip1, *ip2; complex *cp; string *pstring; vector *pvec; double *dp; 我们通过在标识符前加一个解引用操作符 * 来定义指针 在逗号分隔的标识符列表中 每个将被用作指针的标识符前都必须加上解引用操作符 在下面的例子中 lp 是一个指向 long 类型对象的指针 而 lp2 则是一个 long 型的数据对象 不是指针 long *lp, lp2; 在下面的例子中 fp 是一个 float 型的数据对象 而 fp2 是一个指向 float 型对象的指针 float fp, *fp2; 为清楚起见 最好写成 string *ps; 而不是 string* ps; 有可能发生的情况是 当程序员后来想定义第二个字符串指针时 他会错误地修改定义 如下 // 喔: ps2 不是一个字符串指针 string* ps, ps2; 当指针持有 0 值时 表明它没有指向任何对象 或持有一个同类型的数据对象的地址 已知 ival 的定义 int ival = 1024; 下面的定义以及对两个指针 pi 和 pi2 的赋值都是合法的 // pi 被初始化为 "没有指向任何对象" 8 这对函数指针并不成立 函数指针指向程序的代码段 函数指针和数据指针是不同的 函数指针将在 7.9 节说明 73 第三章 C++数据类型 int *pi = 0; // pi2 被初始化为 ival 的地址 int *pi2 = &ival; // ok: pi 和 pi2 现在都指向 ival pi = pi2; // 现在 pi2 没有指向任何对象 pi2 = 0; 指针不能持有非地址值 例如 下面的赋值将导致编译错误 // 错误 pi 被赋以 int 值 ival pi = ival; 指针不能被初始化或赋值为其他类型对象的地址值 例如 已知如下定义 double dval; double *pd = &dval; 那么 下列两条语句都会引起编译时刻错误 // 都是编译时刻错误 // 无效的类型赋值: int* <== double* pi = pd; pi = &dval; 不是说 pi 在物理上不能持有与 dval 相关联内存的地址 它能够 但是不允许 因为 虽然 pi 和 pd 能够持有同样的地址值 但对那块内存的存储布局和内容的解释却完全不同 当然 如果我们要做的仅仅是持有地址值 可能是把一个地址同另一个地址作比较 那么指针的实际类型就不重要了 C++提供了一种特殊的指针类型来支持这种需求 空 void* 类型指针 它可以被任何数据指针类型的地址值赋值 函数指针不能赋值给它 // ok: void* 可以持有任何指针类型的地址值 void *pv = pi; pv = pd; void*表明相关的值是个地址 但该地址的对象类型不知道 我们不能够操作空类型指针 所指向的对象 只能传送该地址值或将它与其他地址值作比较 在 4.14 节我们将会看到更 多关于 void*类型的细节 已知一个 int 型指针对象 pi 当我们写下 pi 时 // 计算包含在 pi 内部的地址值 // 类型 int* pi; 这将计算 pi 当前持有的地址值 当我们写下&pi 时 // 计算 pi 的实际地址 // 类型: int** π 这将计算指针对象 pi 被存储的位置的地址 那么 怎样访问 pi 指向的对象呢 在缺省情况下 我们没有办法访问 pi 指向的对象 以对这个对象进行读或者写的操作 74 第三章 C++数据类型 为了访问指针所指向的对象 我们必须解除指针的引用 C++提供了解引用操作符 * dereference operator 来间接地读和写指针所指向的对象 例如 已知下列定义 int ival = 1024, ival2 = 2048; int *pi = &ival; 下面给出了怎样解引用 pi 以便间接访问 ival // 解除 pi 的引用, 为它所指向的对象 ival // 赋以 ival2 的值 *pi = ival2; // 对于右边的实例, 读取 pi 所指对象的值 // 对于左边的实例 则把右边的表达式赋给对象 *pi = abs( *pi ); // ival = abs(ival); *pi = *pi + 1; // ival = ival + 1; 我们知道 当取一个 int 型对象的地址时 int *pi = &ival; 结果是 int*——即指向 int 的指针 当我们取指向 int 型的指针的地址时 int **ppi = π 结果是 int**——即指向 int 指针的指针 当我们解引用 ppi 时 int *pi2 = *ppi; 我们获得指针 ppi 持有的地址值——在本例中 即 pi 持有的值 而 pi 又是 ival 的地址 为了实际地访问到 ival 我们需要两次解引用 ppi 例如 cout << "The value of ival\n" << "direct value: " << ival << "\n" << "indirect value: " << *pi << "\n" << "doubly indirect value: " << **ppi << endl; 下面两条赋值语句的行为截然不同 但它们都是合法的 第一条语句增加了 pi 指向的数 据对象的值 而第二条语句增加了 pi 包含的地址的值 int i, j, k; int *pi = &i; // i 加 2 (i = i + 2) *pi = *pi + 2; // 加到 pi 包含的地址上 pi = pi + 2; 指针可以让它的地址值增加或减少一个整数值 这类指针操作 被称为指针的算术运算 pointer arithmetic 这种操作初看上去并不直观 我们总认为是数据对象的加法 而不是 离散的十进制数值的加法 指针加 2 意味着给指针持有的地址值增加了该类型两个对象的长 度 例如 假设一个 char 是一个字节 一个 int 是 4 个字节 double 是 8 个字节 那么指针 加 2 是给其持有的地址值增加 2 8 还是 16 完全取决于指针的类型是 char int 还是 double 75 第三章 C++数据类型 实际上 只有指针指向数组元素时 我们才能保证较好地运用指针的算术运算 在前面 的例子中 我们不能保证三个整数变量连续存储在内存中 因此 lp+2 可能 也可能不产生 一个有效的地址 这取决于在该位置上实际存储的是什么 指针算术运算的典型用法是遍历 一个数组 例如 int ia[ 10 ]; int *iter = &ia[0]; int *iter_end = &ia[10]; while ( iter != iter_end ) { do_something_with_value( *iter ); ++iter; // 现在 iter 指向下一个元素 } 练习 3.8 已知下列定义 int ival = 1024, ival2 = 2048; int *pi1 = &ival, *pi2 = &ival2, **pi3 = 0; 说明下列赋值将产生什么后果 哪些是错误的 (a) ival = *pi3; (e) pi1 = *pi3; (b) *pi2 = *pi3; (f) ival = *pi1; (c) ival = pi2; (g) pi1 = ival; (d) pi2 = *pi1; (h) pi3 = &pi2; 练习 3.9 指针是 C 和 C++程序设计一个很重要的方面 也是程序错误的常见起源 例如 pi = &ival2; pi = pi + 1024; 几乎可以保证 pi 会指向内存的一个随机区域 这个赋值在做什么 什么时候它不是一个 错误 练习 3.10 类似地 下面的小程序的行为是未定义的 可能在运行时失败 int foobar( int *pi ) { *pi = 1024; return *pi; } int main() { int *pi2 = 0; int ival = foobar( pi2 ); return 0; } 76 第三章 C++数据类型 问题出在哪里 怎样改正它 练习 3 11 在前面两个练习中 出现错误是因为缺少在运行时刻对指针使用的检查 如果指针在 C++ 程序设计中起重要作用 你认为为什么没有为指针的使用增加更多的安全性 你能想到哪些 指导规则能使指针的使用更加安全 3.4 字符串类型 C++提供了两种字符串的表示 C 风格的字符串和标准 C++引入的 string 类类型 一般 我们建议使用 string 类 但实际上在许多程序的情形中 我们有必要理解和使用老式的 C 风 格字符串 在第 7 章我们会看到一个例子 它处理命令行选项 而这些选项被作为 C 风格的 字符串数组传递给 main()函数 3.4.1 C 风格字符串 C风格的字符串起源于 C 语言 并在 C++中继续得到支持 实际上 在标准 C++之前 除了第三方字符串库类之外 它是惟一一种被支持的字符串 字符串被存储在一个字符数组中 一般通过一个 char*类型的指针来操纵它 标准 C 库 为操纵 C 风格的字符串提供了一组函数 例如 // 返回字符串的长度 int strlen( const char* ); // 比较两个字符串是否相等 int strcmp( const char*, const char* ); // 把第二个字符串拷贝到第一个字符串中 char* strcpy(char*, const char* ); 标准 C 库作为标准的 C++的一部分被包含在其中 为使用这些函数 我们必须包含 相关的 C 头文件 #include 指向 C 风格字符串的字符指针总是指向一个相关联的字符数组 即使当我们写一个字符 串常量时 如 const char *st = "The expense of spirit\n"; 系统在内部也把字符串常量存储在一个字符串数组中 然后 st 指向该数组的第一个元 素 那么 我们怎样以字符串的形式来操纵 st 呢 一般地 我们用指针的算术运算来遍历 C 风格的字符串 每次指针增加 1 直到到达终 止空字符为止 例如 while ( *st++ ) { ... } char*类型的指针被解除引用 并且测试指向的字符是 true 还是 false true 值是除了空字 77 第三章 C++数据类型 符外的任意字符 ++是增加运算符 它使指针对指向数组中的下一个字符 一般来说 当我们使用一个指针时 在解除指针的引用之前 测试它是否指向某个对象 是必要的 否则 程序很可能会失败 例如 int string_length( const char *st ) { int cnt = 0; if ( st ) while ( *st++ ) ++cnt; return cnt; } C风格字符串的长度可以为 0 因而被视为空串 有两种方式 字符指针被置为 0 因 而它不指向任何对象 或者 指针已经被设置 但是它指向的数组只包含一个空字符 如 // pc1 不指向任何一个数组对象 char *pc1 = 0; // pc2 指向空字符 const char *pc2 = ""; 由于 C 风格字符串的底层 low-level 特性 C 或 C++的初学者很容易在这上面出错 在下面的一系列程序中 我们罗列了一些初学者易犯的错误 程序的任务很简单 计算 st 的 长度 不幸的是 第一个尝试就是错误的 你能看到问题所在吗 #include const char *st = "The expense of spirit\n"; int main() { int len = 0; while ( st++ ) ++len; cout << len << "; " << st; return 0; } 程序失败是因为 st 没有被解除引用 即 st++ 测试的是 st 中的地址是否为零 而不是它指向的字符是否为空 这个条件将一直为真 因为循环的每次迭代都给 st 中的地址加 1 程序将永远执行下去或者由系统终止它 这样的 循环被称作无限循环 infinite loop 我们的第二个版本改正了这个错误 它能执行到结束 不幸的是 输出的结果是错误的 你能发现我们这次犯的错误吗 #include const char *st = "The expense of spirit\n"; int main() { int len = 0; while ( *st++ ) ++len; 78 第三章 C++数据类型 cout << len << ": " << st << endl; return 0; } 这次的错误是 st 已经不再指向字符串文字常量 st 已经前进到终止空字符之后的字符上 去了 程序的输出结果取决于 st 所指向的内存单元的内容 下面是一种可能的解决办法 st = st - len; cout << len << ": " << st; 编译并执行程序 但是 输出仍然是不正确的 它产生如下结果 22: he expense of spirit 这反映了程序设计某些本质的方面 你能看到这次我们犯的错误吗 在计算字符串的长度的时候 空字符并没有被考虑在内 st 必须被重新定位到字符串长 度加 1 的位置 下列代码是正确的 st = st - len - 1; 编译并执行 程序最终产生正确的结果如下 22: The expense of spirit 现在程序是正确的了 但是 从程序风格的角度来说 它还有些不太雅致 语句 st = st - len - 1; 被加进来 以便改正由直接递增 st 引起的错误 st 的赋值不符合程序的原始逻辑 而且 现在的程序有些难以理解 像这样的程序修正通常被称作补丁 patch ——把某些东西伸展开以便补上现有程序中 的洞 我们通过补偿原始设计中的逻辑错误来补救我们的程序 较好的解决方案是修正原始 设计中的漏洞 一种方案是定义第二个指针 用 st 对它初始化 例如 const char *p = st; 现在可以用 p 来计算 st 的长度 而 st 不变 while ( *p++ ) 3.4.2 字符串类型 正如我们前面所看到的 因为字符指针的底层特性 用它表示字符串很容易出错 为了 将程序员从许多 与使用 C 风格字符串相关的错误 中解脱出来 每个项目 部门或公司都 提供了自己的字符串类——实际上 本书的前两个版本就是这样做的 问题是 如果每个人 都提供自己的字符串实现 那么程序的可移植性和兼容性就变得非常困难 C++标准库提供 了字符串类抽象的一个公共实现 你希望字符串类有哪些操作呢 最小的基本行为集合出什么构成呢 1 支持用字符序列或第二个字符串对象来初始化一个字符串对象 C 风格的字符串不支 持用另外一个字符串初始化一个字符串 2 支持字符串之间的拷贝 C 风格字符串通过使用库的数 strcpy()来实现 79 第三章 C++数据类型 3 支持读写访问单个字符 对于 C 风格字符串 单个字符访问由下标操作符或直接解 除指针引用来实现 4 支持两个字符串的相等比较 对于 C 风格字符串 字符串比较通过库函数 strcmp() 来实现 5 支持两个字符串的连接 把一个字符串接到另一个字符串上 或将两个字符串组合起 来形成第三个字符串 对于 C 风格的字符串 连接由库函数 strcat()来实现 把两个字符串连 接起来形成第三个字符串的实现是 用 strcpy()把一个字符串拷贝到一个新实例中 然后用 strcat()把另一个字符串连接到新的实例上 6 支持对字符串长度的查询 对于 C 风格字符串 字符串长度由库函数 strlen()返回 7 支持字符串是否为空的判断 对于 C 风格字符串 通过下面两步条件测试来完成 char *str = 0; // ... if ( ! str || ! *str ) return; 标准 C++提供了支持这些操作的 string 类 在第 6 章我们会看到更多的操作 在本小 节中 让我们来看 string 类型怎样支持这些操作 要使用 string 类型 必须先包含相关的头文件 #include 例如 下面是上一小节定义的字符数组 #include string st( "The expense of spirit\n" ); st的长度由 size()操作返回 不包含终止空字符 cout << "The size of " << st << " is " << st.size() << " characters, including the newline\n"; string构造函数的第二种形式定义了一个空字符串 例如 string st2; // 空字符串 我们怎样能保证它是空的 当然 一种办法是测试 size()是否为 0 if ( ! st.size() ) // ok: 空 更直接的办法是使用 empty()操作 if ( st.empty() ) // ok: 空 如果字符串中不含有字符 则 empty()返回布尔常量 true 否则 返回 false 第三种形式的构造函数 用一个 string 对象来初始化另一个 string 对象 例如 string st3( st ); 将 st3 初始化成 st 的一个拷贝 怎样验证呢 等于操作符比较两个 string 对象 如果相 80 第三章 C++数据类型 等则返回 true if ( st == st3 ) // 初始化成功 怎样拷贝一个字符串呢 最简单的办法是使用赋值操作符 例如 st2 = st3; // 把 st3 拷贝到 st2 中 首先将与 st2 相关联的字符存储区释放掉 然后再分配足够存储与 st3 相关联的字符的存 储区 最后将与 st3 相关联的字符拷贝到该存储区中 我们可以使用加操作符 + 或看起来有点怪异的复合赋值操作符 += 将两个或多 个字符串连接起来 例如 给出两个字符串 string s1( "hello, " ); string s2( "world\n" ); 我们可以按如下方式将两个字符串连接起来形成第三个字符串 string s3 = s1 + s2; 如果希望直接将 s2 附加在 s1 后面 那么可使用 += 操作符 s1 += s2; s1和 s2 的初始化包含了一个空格 一个逗号以及一个换行 这多少有些不方便 它们 的存在限制了对这些 string 对象的重用 尽管它满足了眼前的需要 一种替代做法就是混合 使用 C 风格的字符串与 string 对象 如下所示 const char *pc = ", "; string s1( "hello" ); string s2( "world" ); string s3 = s1 + pc + s2 + "\n"; 这种连接策略比较受欢迎 因为它使 s1 和 s2 处于一种更容易被重用的形式 这种方法 能够生效是由于 string 类型能够自动将 C 风格的字符串转换成 string 对象 例如 这使我们 可以将一个 C 风格的字符串赋给一个 string 对象 string s1; const char *pc = "a character array"; s1 = pc; // ok 但是 反向的转换不能自动执行 对隐式地将 string 对象转换成 C 风格的字符串 string 类型没有提供支持 例如 下面试图用 s1 初始化 str 就会在编译时刻失败 char *str = s1; // 编译时刻类型错误 为实现这种转换 必须显式地调用名为 c_str()的操作 char *str = s1.c_str(); // 几乎是正确的 但是还差一点 名字 c_str()代表了 string 类型与 C 风格字符串两种表示法之间的关系 字面意思是 给 我一个 C 风格的字符串表示——即 指向字符数组起始处的字符指针 但是 这个初始化还是失败了 这次是由于另外一个不同的原因 为了防止字符数组被 81 第三章 C++数据类型 程序直接处理 c_str()返回了一个指向常量数组的指针 下一节将解释常量修饰符 const const char* str被定义为非常量指针 所以这个赋值被标记为类型违例 正确的初始化如下 const char *str = s1.c_str(); // ok string类型支持通过下标操作符访问单个字符 例如 在下面的代码段中 字符串中的 所有句号被下划线代替 string str( "fa.disney.com" ); int size = str.size(); for ( int ix = 0; ix < size; ++ix ) if ( str[ ix ] == '.' ) str[ ix ] = '_'; 对 string 类型的介绍现在就讲这些 尽管我们还有许多内容要说 例如 上面代码段的 实现可用如下语句替代 replace( str.begin(), str.end(), '.', '_' ); replace()是 2.8 节中简要介绍的泛型算法中的一个 第 12 章将详细介绍泛型算法 本书 附录按字母顺序给出了泛型算法及其用法的例子 begin()和 end()操作返回指向 string 开始和结束处的迭代器(iterator) 迭代器是指针的 类抽象 由标准库提供 在 2.8 节中我们简要地介绍了迭代器 在第 6 章和第 12 章将详细介 绍 replace()扫描 begin()和 end()之间的字符 每个等于句号的字符 都被替换成下划线 练习 3.12 下列语句哪些是错误的 (a) char ch = "The long, winding road"; (b) int ival = &ch; (c) char *pc = &ival; (d) string st( &ch ); (e) pc = 0; (i) pc = '0'; (f) st = pc; (j) st = &ival; (g) ch = pc[0]; (k) ch = *pc; (h) pc = st; (l) *pc = ival; 练习 3.13 解释下面两个 while 循环的区别 while ( st++ ) ++cnt; while ( *st++ ) ++cnt; 82 第三章 C++数据类型 练习 3.14 考虑下面两个语义上等价的程序 一个使用 C 风格字符串 另一个使用 string 类型 // ***** C-style character string implementation ***** #include #include int main() { int errors = 0; const char *pc = "a very long literal string"; for ( int ix = 0; ix < 1000000; ++ix ) { int len = strlen( pc ); char *pc2 = new char[ len + 1 ]; strcpy( pc2, pc ); if ( strcmp( pc2, pc )) ++errors; delete [] pc2; } cout << "C-style character strings: " << errors << " errors occurred.\n"; } // ***** string implementation ***** #include #include int main() { int errors = 0; string str( "a very long literal string" ); for ( int ix = 0; ix < 1000000; ++ix ) { int len = str.size(); string str2 = str; if ( str != str2 ) ++errors; } cout << "string class: " << errors << " errors occurred.\n"; } a) 说明程序完成了什么功能 b) 平均来说 string 类型实现的执行速度是 C 风格字符串的两倍 在 UNIX 的 timex 命令下显示的执行时间如下 user 0.96 # string class user 83 第三章 C++数据类型 user 1.98 # C-style character string 你是这样预想的吗 说明原因 练习 3.15 C++的 string 类型是基于对象的类抽象的一个例子 对于本节中所介绍的关于它的用法 及操作集 你有什么希望改变的吗 你认为还有哪些其他操作是必需的 有用的 请说明 3.5 const 限定修饰符 下面的循环有两个问题 都是由于使用 512 作为循环上限引起的 for ( int index = 0; index < 512; ++index ) 第一个问题是可读性 用 512 来测试 index 是什么意思呢 循环在做什么呢——即 512 是什么意思 在本例中 512 被称作魔数 magic number 它的重要性在上下文中没有 体现出来 就好像这个数是凭空出现的 第二个问题是可维护性 想像程序有 10000 行 512 在 4 的代码中出现 在这 400 个出 现中 80 必须要被改成 1024 为了做到这一点 我们必须明白哪些 512 是要被转换的 而 哪些不是 即使只有一个地方弄错了 也会中断程序 要我们回头全部重新检查一遍 这两个问题的解决方案就是使用一个被初始化为 512 的对象 通过选择一个助记名 可 能是 bufSize 使程序更具可读性 现在 条件测试变成与对象作比较 而不是与一个文字常 量作比较 index < bufSize 我们不需要再把 320 个出现 512 的地方——找出来 只需改变 bufSize 的值就行了 我 们只需改变 bufSize 被初始化的那一行 这种方法不仅只需要很少的工作量 而且大大减少 了出错的可能性 这种方案的代价是一个额外的变量 现在 512 被称为是局部化的 localized int bufSize = 512; // 缓冲区大小 // ... for ( int index = 0; index < bufSize; ++index ) // ... 这种方案的问题是 bufSize 是一个左值 在程序中 bufSize 有可能被偶然修改 例如 下面是一个常见的程序错误 // 偶然地改变了 bufSize 的值 if ( bufSize = 1 ) // ... 在 C++中 = 是赋值操作符 而 == 是等于操作符 程序员不小心将 bufSize 的值 改成 1 将导致了一个很难跟踪的错误 这种错误很难被发现 因为程序员一般不会认为 这行代码是错的 这就是为什么许多编译器会对此类的赋值表达式生成警告的原因 const类型限定修饰符提供了一个解决方案 它把一个对象转换成一个常量 constant 84 第三章 C++数据类型 例如 const int bufSize = 512 // 缓冲区大小 定义 bufSize 是一个常量 并将其初始化为 512 在程序中任何改变这个值的企图都将导 致编译错误 因此 它被称为是只读的 read- only 例如 // 错误 企图写入 const 对象 if ( bufsize = 0 ) ... 因为常量在定义后就不能被修改 所以它必须被初始化 未初始化的常量定义将导致编 译错误 const double pi; // 错误: 未初始化的常量 一旦一个常量被定义了 我们就不能改变与 const 对象相关联的值 另一方面 我们能 把它的地址赋值给一个指针吗 例如 下面代码是否可行 const double minWage = 9.60; // ok? error? double *ptr = &minWage; 这是否可行呢 minWage 是一个常量对象 因此它不能被改写为一个新的值 但是 ptr 是一个普通指针 没有什么能阻止我们写出这样的代码 *ptr += 1.40; // 修改了 minwage! 一般编译器不能跟踪指针在程序中任意一点指向的对象 这种内部工作需要进行数据 流分析 data flow analysis 通常由单独的优化器 optimizer 组件来完成 允许非 const 对象的指针指向一个常量对象 把 试图通过该指针间接地改变对象值 的动作标记为非法 的 这对编译器来说是不可行的 因而任何 试图将一个非 const 对象的指针指向一个常量 对象 的动作都将引起编译错误 这并不意味着我们不能间接地指向一个 const 对象 只意味着我们必须声明一个指向常 量的指针来做这件事 例如 const double *cptr; cptr是一个指向 double 类型的 const 对象的指针 我们可以从右往左把这个定义读为 cptr 是一个指向 double 类型的 被定义成 const 的对象的指针 此中微妙在于 cptr 本 身不是常量 我们可以重新赋值 cptr 使其指向不同的对象 但不能修改 cptr 指向的对象 例如 const double *pc = 0; const double minWage = 9.60; // ok: 不能通过 pc 修改 minWage pc = &minWage; double dval = 3.14; // ok: 不能通过 pc 修改 dval // 虽然 dval 本身不是一个常量 pc = &dval; // ok 85 第三章 C++数据类型 dval = 3.14159; // ok *pc = 3.14159; // 错误 const对象的地址只能赋值给指向 const 对象的指针 例如 pc 但是 指向 const 对象的 指针可以被赋以一个非 const 对象的地址 例如 pc = &dval; 虽然 dval 不是常量 但试图通过 pc 修改它的值 仍会导致编译错误 因为在运行程序 的任意一点上 编译器不能确定指针所指的实际对象 在实际的程序中 指向 const 的指针常被用作函数的形式参数 它作为一个约定来保证 被传递给函数的实际对象在函数中不会被修改 例如 // 在实际的程序中, 指向常量的指针 // 往往被用作函数参数 int strcmp( const char *str1, const char *str2 ); 在第 7 章关于函数的讨论中我们会更多地讨论指向 const 对象的指针 我们可以定义一个 const 指针指向一个 const 或一个非 const 对象 例如 int errNumb = 0; int *const curErr = &errNumb; curErr是指向一个非 const 对象的 const 指针 我们可以从右拄左把定义读作 curErr 是一个指向 int 类型对象的 const 指针 这意味着不能赋给 curErr 其他的地址值 但可以 修改 curErr 指向的值 下面的代码说明我们可以怎样使用 curErr do_something(); if ( *curErr ) { errorHandler(); *curErr = 0; // ok: 重置指针所指的对象 } 试图给 const 指针赋值会在编译时刻被标记为错误 curErr = &myErrNumb; // 错误 指向 const 对象的 const 指针的定义就是将前面两种定义结合起来 例如 const double pi = 3.14159; const double *const pi_ptr = π 在这种情况下 pi_ptr 指向的对象的值以及它的地址本身都不能被改变 我们可以从 右往左将定义读作 pi_ptr 是指向被定义为 const 的 double 类型对象的 const 指针 练习 3.16 解释下列五个定义的意思 并指出其中非法的定义 (a) int i; (d) int *const cpi; (b) const int ic; (e) const int *const cpic; (c) const int *pic; 86 第三章 C++数据类型 练习 3.17 下列哪些初始化是合法的 为什么 (a) int i = -1; (b) const int ic = i; (c) const int *pic = ⁣ (d) int *const cpi = ⁣ (e) const int *const cpic = ⁣ 练习 3.18 根据上个练习的定义 下列哪些赋值是合法的 为什么 (a) i = ic; (d) pic = cpic; (b) pic = ⁣ (e) cpic = ⁣ (c) cpi = pic; (f) ic = *cpic; 3.6 引用类型 引用 reference 有时候又称为别名 alias 它可以用作对象的另一个名字 通过引 用我们可以间接地操纵对象 使用方式类似于指针 但是不需要指针的语法 在实际的程序 中 引用主要被用作函数的形式参数——通常将类对象传递给一个函数 但是现在我们用独 立的对象来介绍并示范引用的用法 引用类型由类型标识符和一个取地址操作符来定义 引用必须被初始化 例如 int ival = 1024; // ok: refVal 是一个指向 ival 的引用 int &refVal = ival; // 错误 引用必须被初始化为指向一个对象 int &refVal2; 虽然引用也可以被用作一种指针 但是像对指针那样用一个对象的地址初始化引用 却是 错误的 然而 我们可以定义一个指针引用 例如 int ival = 1024; // 错误 refVal 是 int 类型, 不是 int* int &refVal = &ival; int *pi = &ival; // ok: refPtr 是一个指向指针的引用 int *&ptrVal2 = pi; 一旦引用已经定义 它就不能再指向其他的对象 这是它为什么必须要被初始化的原因 例如 下列的赋值不会使 refVal 指向 min_val 而是会使 refVal 指向的对象 ival 的值被 87 第三章 C++数据类型 设置为 min_val 的值 int min_val = 0; // ival 被设置为 min_val 的值 // refVal 并没有引用到 min_val 上 refVal = min_val; 引用的所有操作实际上都被应用在它所指的对象身上 包括取地址操作符 例如 refVal += 2; 将 refVal 指向的对象 ival 加 2 类似地 如下语句 int ii = refVal; 把与 ival 相关联的值赋给 ii 而 int *pi = &refVal; 用 ival 的地址初始化 pi 每个引用的定义必须以取地址操作符开始 这与前面我们对指针的讨论是同样的问 题 例如 // 定义两个 int 类型的对象 int ival = 1024, ival2 = 2048; // 定义一个引用和一个对象 int &rval = ival, rval2 = ival2 // 定义一个对象 一个指针和一个引用 int ival3 = 1024, *pi = &ival3, &ri = ival3; // 定义两个引用 int &rval3 = ival3, &rval4 = ival2; const引用可以用不同类型的对象初始化 只要能从一种类型转换到另一种类型即可 也可以是不可寻址的值 如文字常量 例如 double dval = 3.14159; // 仅对于 const 引用才是合法的 const int &ir = 1024; const int &ir2 = dval; const double &dr = dval + 1.0; 同样的初始化对于非 const 引用是不合法的 将导致编译错误 原因有些微妙 需要适 当作些解释 引用在内部存放的是一个对象的地址 它是该对象的别名 对于不可寻址的值 如文字 常量 以及不同类型的对象 编译器为了实现引用 必须生成一个临时对象 引用实际上指 向该对象 但用户不能访问它 例如 当我们写 double dval = 1024; const int &ri = dval; 编译器将其转换成 88 第三章 C++数据类型 int temp = dval; const int &ri = temp; 如果我们给 ri 赋一个新值 则这样做不会改变 dval 而是改变 temp 对用户来说 就好 像修改动作没有生效 这对于用户来说 这并不总是好事情 const引用不会暴露这个问题 因为它们是只读的 不允许非 const 引用指向需要临时对 象的对象或值 一般来说 这比 允许定义这样的引用 但实际上不会生效 的方案要好 得多 下面给出的例子很难在第一次就能正确声明 我们希望用一个 const 对象的地址来初始 化一个引用 非 const 引用定义是非法的 将导致编译时刻错误 const int ival = 1024; // 错误: 要求一个 const 引用 int *&pi_ref = &ival; 下面是我们在打算修正 pi_ref 定义时首先想到的做法 但是它不能生效——你能看出来 这是为什么吗 const int ival = 1024; // 仍然错误 const int *&pi_ref = &ival; 如果我们从右向左读这个定义 会发现 pi_ref 是一个指向定义为 const 的 int 型对象的指 针 我们的引用不是指向一个常量 而是指向一个非常量指针 指针指向一个 const 对象 正确的定义如下 const int ival = 1024; // ok: 这是可以被编译器接受的 const int *const &pi_ref = &ival; 指针和引用有两个主要区别 引用必须总是指向一个对象 如果用一个引用给另一个引 用赋值 那么改变的是被引用的对象而不是引用本身 让我们来看几个例子 当我们这样写 int *pi = 0; 用 0 初始化 pi——即 pi 当前不指向任何对象 但当我们写如下语句时 const int &ri = 0; 在内部 发生了以下转换 int temp = 0; const int &ri = temp; 引用之间的赋值是第二个不同 当给定了以下代码后 int ival = 1024, ival2 = 2048; int *pi = &ival, *pi2 = &ival2; 我们再写如下语句 pi = pi2; 89 第三章 C++数据类型 pi指向的对象 ival 并没有被改变 实际上 pi 被赋值为指向 pi2 所指的对象——在本例中 即 ival2 重要的是 现在 pi 和 pi2 都指向同一对象 这是一个重要的错误源 如果我们把 一个类对象拷贝给另一个类对象 而该类有一个或多个成员是指针 我们将在第 14 章详细讨 论这个问题 但是 假定有下列代码 int &ri = ival, &ri2 = ival2; 当我们写出这样的赋值语句时 ri = ri2; 改变的是 ival 而不是引用本身 赋值之后 两个引用仍然指向原来的对象 实际的 C++程序很少使用指向独立对象的引用类型 引用类型主要被用作函数的形式参 数 例如 // 在实际的例子中 引用是如何被使用的 // 返回访问状态 将值放入参数 bool get_next_value( int &next_value ); // 重载加法操作符 Matrix operator+( const Matrix&, const Matrix& ); 这些引用的用法和我们讨论的指向独立对象的引用类型有什么联系呢 在下面这样的调 用中 int ival; while ( get_next_value( ival )) ... 实际参数 本例中为 ival 同形式参数 next_value 的绑定 等价于下面的独立对象定义 int &next_value = ival; 引用作为函数参数的用法将在第 7 章中详细讨论 练习 3.19 下列定义中 哪些是无效的 为什么 怎样改正 (a) int ival = 1.01; (b) int &rval1 = 1.01; (c) int &rval2 = ival; (d) int &rval3 = &ival; (e) int *pi = &ival; (f) int &rval4 = pi; (g) int &rval5 = *pi; (h) int &*prval1 = pi; (i) const int &ival2 = 1; (j) const int &*prval2 = &ival; 练习 3.20 已知下面的定义 下列赋值哪些是无效的 (a) rval1 = 3.14159; (b) prval1 = prval2; (c) prval2 = rval1; (d) *prval2 = ival2; 90 第三章 C++数据类型 练习 3.21 (a)中的定义有什么区别 (b)中的赋值又有什么区别 哪些是非法的 (a) int ival = 0; const int *pi = 0; const int &ri = 0; (b) pi = &ival; ri = &ival; pi = &rval; 3.7 布尔类型 布尔型对象可以被赋以文字值 true 或 false 例如 // 初始化一个 string 对象 用来存放搜索的结果 string search_word = get_word(); // 把一个 bool 变量初始化为 false bool found = false; string next_word; while ( cin >> next_word ) if ( next_word == search_word ) found = true; // ... // 缩写, 相当于: if ( found == true ) if ( found ) cout << "ok, we found the word\n"; else cout << "nope, the word was not present.\n"; 虽然布尔类型的对象也被看作是一种整数类型的对象 但是它不能被声明为 signed unsigned short 或 long 例如 下列代码是非法的 // 错误 不能指定 bool 为 short short bool found = false; 当表达式需要一个算术值时 布尔对象(如 found)和布尔文字都被隐式地提升成 int(正 如下面的例子) false 变成 0 而 true 变成 1 例如 bool found = false; int occurrence_count = 0; while ( /* 条件省略 */ ) { found = look_for( /* 内容省略 */ ); // found 的值被提升为 0 或者 1 occurrence_count += found; } 正如文字 false 和 true 能自动转换成整数值 0 和 1 一样 如果有必要 算术值和指针值也 91 第三章 C++数据类型 能隐式地被转换成布尔类型的值 0 或空指针被转换成 false 所有其他的值都被转换成 true 例如 // 返回出现次数 extern int find( const string& ); bool found = false; if ( found = find( "rosebud" )) // ok: found == true // 如找到返回该项的指针 extern int* find( int value ); if ( found = find( 1024 )) // ok: found == true 3.8 枚举类型 我们在写程序的时候 常常需要定义一组与对象相关的属性 例如 一个文件可能会以 三种状态 输入 输出和追加 之一被打开 典型情况下 我们通过把每个属性和一个唯一的 const 值相关联 来记录这些状态值 因此 我们可能会这样写 const int input = 1; const int output = 2; const int append = 3; 并按如下方式使用这些常量 bool open_file( string file_name, int open_mode); // ... open_file( "Phoenix_and_the_Crane", append ); 尽管这样做也能奏效 但是它有许多缺点 一个主要的缺点是 我们没有办法限制传递 给函数的值只能是 input output 和 append 之一 枚举 enumeration 提供了一种替代的方法 它不但定义了整数常量 而且还把它们组 成一个集合 例如 enum open_modes{ input = 1, output, append }; open_modes是一个枚举类型 每个被命名的枚举定义了一个唯一的类型 它可以被用作 类型标识符 例如 void open_file( string file_name, open_modes om ); input output 和 append 是枚举成员 enumerator 它们代表了能用来初始化和赋值 open_modes 类型变量的值的全集 例如 open_file( "Phoenix and the Crane", append ); 如果我们试图向 open_file()传递一个 input output append 之外的值 就会产生编译错 误 而且 如果像下面这样传递一个相等的整数值 编译器仍然会将其标记为错误 92 第三章 C++数据类型 // 错误 1 不是 open_modes 的枚举成员 ... open_file( "Jonah", 1 ); 此外 我们还可以声明枚举类型对象 如 open_modes om = input; // ... om = append; 并用 om 代替一个枚举成员 open_file( "TailTell", om ); 我们不能做到的是打印枚举成员的实际枚举名 当我们这样写的时候 cout << input << " " << om << endl; 输出为 13 一种解决方案是定义一个由枚举成员的值索引的字符串数组 因此 我们可以这样写 cout << open_modes_table[ input ] << " " << open_modes_table[ om ] << endl; 产生输出 input append 第二件不能做的事情是 我们不能使用枚举成员进行迭代 如 // 不支持 for ( open_modes iter = input; iter != append; ++iter ) // ... C++不支持在枚举成员之间的前后移动 枚举类型用关键字 enum 加上一个自选的枚举类型名来定义 类型名后面跟一个用花括 号括起来的枚举成员列表 枚举成员之间用逗号分开 在缺省情况下 第一个枚举成员被赋 以值 0 后面的每个枚举成员依次比前面的大 1 在前面的例子中 赋给 input 值 1 output 值 2 append 值 3 下面的枚举成员 shape 与 0 相关 sphere 是 1 cylinder 是 2 polygon 是 3 // shape == 0, sphere == 1, cylinder == 2, polygon == 3 enum Forms{ shape, sphere, cylinder, polygon }; 我们也可以显式地把一个值赋给一个枚举成员 这个值不必是唯一的 下面的例子 中 point2d 被赋值为 2 在缺省情况下 point2w 等于 point2d 加 1 为 3 point3d 被显式地赋 值为 3 point3w 在缺省情况下是 4 // point2d == 2, point2w == 3, point3d == 3, point3w == 4 enum Points { point2d = 2, point2w, point3d = 3, point3w }; 我们可以定义枚举类型的对象 它可以参与表达式运算 也叫以被作为参数传递给函数 枚举类型的对象能够被初始化 但是只能被一个相同枚举类型的对象或枚举成员集中的某个 值初始化或赋值 例如 虽然 3 是一个与 Points 相关联的合法值 但是它不能被显式地赋给 Points 类型的对象 93 第三章 C++数据类型 void mumble() { Points pt3d = point3d; // ok: pt3d == 3 // 错误 pt2w 被初始化为一个 int 整数 Points pt2w = 3; // 错误 polygon 不是 Points 的枚举成员 pt2w = polygon; // ok: pt2w 和 pt3d 都是 Points 枚举类型 pt2w = pt3d; } 但是 在必要时 枚举类型会自动被提升成算术类型 例如 const int array_size = 1024; // ok: pt2w 被提升成 int 类型 int chunk_size = array_size * pt2w; 3.9 数组类型 正如我们在 2.1 节中所看到的 数组是一个单一数据类型对象的集合 其中单个对象并 没有被命名 但是我们可以通过它在数组中的位置对它进行访问 这种访问形式被称作索引 访问 indexing 或下标访问 subscripting 例如 int ival; 声明了一个 int 型对象 而如下形式 int ia[10]; 声明了一个包含 10 个 int 对象的数组 每个对象被称作是 ia 的一个元素 因此 ival = ia[ 2 ]; 将 ia 中由 2 索引的元素的值赋给 ival 类似地 ia[ 7 ] = ival; 把 ival 的值赋给 ia 的由 7 索引的元素 数组定义由类型名 标识符和维数组成 维数指定数组中包含的元素的数目 它被写在 一对方括号里边 我们必须为数组指定一个大于等于 1 的维数 维数值必须是常量表达式—— 即 必须能在编译时刻计算出它的值 这意味着非 const 的变量不能被用来指定数组的维数 下面的例子包含合法的和非法的数组定义 extern int get_size(); // buf_size 和 max_files 都是 const const int buf_size = 512, max_files = 20; int staff_size = 27; // ok: const 变量 94 第三章 C++数据类型 char input_buffer[ buf_size ]; // ok 常量表达式: 20 - 3 char *fileTable[ max_files - 3 ]; // 错误: 非 const 变量 double salaries[ staff_size ]; // 错误 非 const 表达式 int test_scores[ get_size() ]; 虽然 staff_size 被一个文字常量初始化 但是 staff_size 本身是一个非 const 对象 系统只 能在运行时刻访问它的值 因此 它作为数组维数是非法的 另一方面 表达式 max_files - 3 是常量表达式 因为 max_files 是用 20 作初始值的 const 变量 所以这个表达式在编译 时刻被计算成 17 正如我们在 2.1 节所看到的 数组元素是从 0 开始计数的 对一个包含 10 个元素的数组 正确的索引值是从 0 到 9 而不是从 1 到 10 在下面的例子中 for 循环遍历数组的 10 个元 素 并用它们的索引值作初始值 int main() { const int array_size = 10; int ia[ array_size ]; for ( int ix = 0; ix < array_size; ++ix ) ia[ ix ] = ix; } 数组可以被显式地用一组数来初始化 这组数用逗号分开 放在大括号中 例如 const int array_size = 3; int ia[ array_size ] = { 0, 1, 2 }; 被显式初始化的数组不需要指定维数值 编译器会根据列出来的元素的个数来确定数组 的维数 // 维数为 3 的数组 int ia[] = { 0, 1, 2 }; 如果指定了维数 那么初始化列表提供的元素的个数不能超过这个值 否则 将导致编 译错误 如果指定的维数大于给出的元素的个数 那么没有被显式初始化的元素将被置为 0 // ia ==> { 0, 1, 2, 0, 0 } const int array_size = 5; int ia[ array_size ] = { 0, 1, 2 }; 字符数组可以用一个由逗号分开的字符文字列表初始化 文字列表用花括号括起来 或 者用一个字符串文字初始化 但是 注意这两种形式不是等价的 字符串常量包含一个额外 的终止空字符 例如 const char ca1[] = { 'C', '+', '+' }; const char ca2[] = "C++"; 95 第三章 C++数据类型 cal的维数是 3 ca2 的维数是 4 下面的声明将被标记为错误 // 错误: "Daniel"是 7 个元素 const char ch3[ 6 ] = "Daniel"; 一个数组不能被另外一个数组初始化 也不能被赋值给另外一个数组 而且 C++不允 许声明一个引用数组(即由引用组成的数组) const int array_size = 3; int ix, jx, kx; // ok: 类型为 int*的指针的数组 int *iap [] = { &ix, &jx, &kx }; // 错误: 不允许引用数组 int &iar[] = { ix, jx, kx }; // 错误: 不能用另一个数组来初始化一个数组 int ia2[] = ia; // 错误 int main() { int ia3[ array_size ]; // ok // 错误: 不能把一个数组赋给另一个数组 ia3 = ia; return 0; } 要把一个数组拷贝到另一个中去 必须按顺序拷贝每个元素 例如 const int array_size = 7; int ia1[] = { 0, 1, 2, 3, 4, 5, 6 }; int main() { int ia2[ array_size ]; for ( int ix = 0; ix < array_size; ++ix ) ia2[ ix ] = ia1[ ix ]; return 0; } 任意结果为整数值的表达式都可以用来索引数组 例如 int someVal, get_index(); ia2[ get_index() ] = someVal; 但是用户必须清楚 C++没有提供编译时刻或运行时刻对数组下标的范围检查 除了程 序员自己注意细节 并彻底地测试自己的程序之外 没有别的办法可防止数组越界 能够通 过编译并执行的程序仍然存在致命的错误 这不是不可能的 练习 3.22 下面哪些数组定义是非法的 为什么 96 第三章 C++数据类型 int get_size(); int buf_size = 1024; (a) int ia[ buf_size ]; (d) int ia[ 2 * 7 - 14 ]; (b) int ia[ get_size() ]; (e) char st[ 11 ] = "fundamental"; (c) int ia[ 4 * 7 - 14 ]; 练习 3.23 下面代码试图用数组中每个元素的索引值来初始化该元素 其中包含了一些索引错误 请把它们指出来 int main() { const int array_size = 10; int ia[ array_size ]; for ( int ix = 1; ix <= array_size; ++ix ) ia[ ix ] = ix; // ... } 3.9.1 多维数组 我们也可以定义多维数组 每一维用一个方括号对来指定 例如 int ia[ 4 ] [ 3 ]; 定义了一个二维数组 第一维被称作行 row 维 第二维称作列 column 维 ia 是 一个二维数组 它有 4 行 每行 3 个元素 多维数组也可以被初始化 int ia[ 4 ][ 3 ] = { { 0, 1, 2 }, { 3, 4, 5 }, { 6, 7, 8 }, { 9, 10, 11 } } 用来表示行的花括号 即被内嵌在里边的花括号是可选的 下面的初始化与前面的是等 价的 只是有点不清楚 int ia[4][3] = { 0,1,2,3,4,5,6,7,8,9,10,11 }; 下面的定义只初始化了每行的第一个元素 其余的元素被初始化为 0 int ia[ 4 ][ 3 ] = { {0}, {3}, {6}, {9} }; 如果省略了花括号 结果会完全不同 下面的定义 int ia[ 4 ][ 3 ] = { 0, 3, 6, 9 }; 初始化了第一行的 3 个元素和第二行的第一个元素 其余元素都被初始化为 0 为了索 引到一个多维数组中 每一维都需要一个方括号对 例如 下面的一对嵌套 for 循环初始化 了一个二维数组 int main() 97 第三章 C++数据类型 { const int rowSize = 4; const int colSize = 3; int ia[ rowSize ][ colSize ]; for ( int i = 0; i < rowSize; ++i ) for ( int j = 0; j < colSize; ++j ) ia[ i ][ j ] = i + j; } 虽然表达式 ia[ 1, 2 ] 在 C++中是合法的结构 但它的意思可能不是程序员所希望的 ia[1,2]等价于 ia[2] 因 为 1,2 是一个逗号表达式 它的结果是一个单值 2 逗号表达式将在 4.10 节中讨论 这 将访问 ia 的第三行的第一个元素 而程序员希望的可能是 ia[1][2] 在 C++中 多维数组的索引访问要求对程序员希望访问的每个索引都有一对方括号 3.9.2 数组与指针类型的关系 已知下面的数组定义 int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 }; 那么 只简单写 ia; 意味着什么呢 数组标识符代表数组中第一个元素的地址 它的类型是数组元素类型的指针 在 ia 这个 例子中 它的类型是 int* 因此 下面两种形式是等价的 它们都返回数组的第一个元素的 地址 ia; &ia[0]; 类似地 为了访问相应的值 我们可以取下列两种方式之一 // 两者都得到第一个元素的值 *ia; ia[0]; 我们知道怎样用下标操作符来访问第二个元素的地址 &ia[1]; 同样 下面这个表达式 ia+1; 也能得到第二个元素的地址等等 类似地 下面两个表达式都可以访问第二个元素的值 *(ia+1); ia[1]; 但是 如下的表达式 98 第三章 C++数据类型 *ia + 1; 与下面的表达式完全不同 *(ia + 1); 解引用操作符比加法运算符的优先级高 我们将在 4.13 节中讨论优先级 所以它先被 计算 解引用 ia 将返回数组的第一个元素的值 然后对其加 1 如果在表达式里加上括号 那么 ia 将先被加 1 然后解引用新的地址值 对 ia 加 1 将使 ia 增加其元素类型的大小 ia+1 指向数组中的下一个元素 数组元素遍历则可以通过下标操作符来实现 到目前为止我们一直这样做 或者我们也 可以通过直接操作指针来实现数组元素遍历 例如 #include int main() { int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 }; int *pbegin = ia; int *pend = ia + 9; while ( pbegin != pend ) { cout << *pbegin << ' '; ++pbegin; } } pbegin被初始化指向数组的第一个元素 在 while 循环的每次迭代中它都被递增以指向 数组的下一个元素 最难的是判断何时停止 在本例中 我们将 pend 初始化指向数组最末元 素的下一个地址 当 pbegin 等于 pend 时 表示已经迭代了整个数组 如果我们把这一对指向数组头和最末元素下一位置的指针 抽取到一个独立的函数中 那么 就有了一个能够迭代整个数组的工具 却无须知道数组的实际大小 当然 调用函数 的程序员必须知道 例如 #include void ia_print( int *pbegin, int *pend ) { while ( pbegin != pend ) { cout << *pbegin << ' '; ++pbegin; } } int main() { int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 }; ia_print( ia, ia + 9 ); } 当然 这是有限制的 它只支持指向整型数组的指针 我们可以通过把 ia_print()转换成 模板函数来消除这个限制 在 2.5 节我们已经简要地介绍了模板 例如 99 第三章 C++数据类型 #include template void print( elemType *pbegin, elemType *pend ) { while ( pbegin != pend ) { cout << *pbegin << ' '; ++pbegin; } } 现在我们可以给通用的函数 print()传递一对指向任意类型数组的指针 只要该类型的输 出操作符已经被定义即可 例如 int main() { int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 }; double da[4] = { 3.14, 6.28, 12.56, 25.12 }; string sa[3] = { "piglet", "eeyore", "pooh" }; print( ia, ia+9 ); print( da, da+4 ); print( sa, sa+3 ); } 这种程序设计形式被称为泛型程序设计 generic programming 标准库提供了一组泛 型算法 我们在 2.8 节和 3.4 节结束的时候简要地介绍了这些算法 它们通过一对标记元 素范围的开始/结束指针来遍历其中的元素 例如 我们可以如下调用泛型算法 sort() #include int main() { int ia[6] = { 107, 28, 3, 47, 104, 76 }; string sa[3] = { "piglet", "eeyore", "pooh" }; sort( ia, ia+6 ); sort( sa, sa+3 ); } 我们将在第 12 章详细讨论泛型算法 本书附录以字母顺序给出这些算法以及用法示例 更一般化的是 标准库提供了一组类 它们封装了容器和指针的抽象 在 2.8 节我们己 经对其进行了简要的介绍 在下一节中 我们将讨论 vector 容器类型 它为内置数组提供了 一个基于对象的替代品 3.10 vector 容器类型 vector类为内置数组提供了一种替代表示 在 2.8 节中我们简要介绍了 vector 通常我 们建议使用 vector 但是仍然有许多程序环境必须使用内置数组 例如处理命令行选项—— 我们将在 7.8 节中可以看到 与 string 类一样 vector 类是随标准 C++引入的标准库的一部 分 为了使用 vector 我们必须包含相关的头文件 100 第三章 C++数据类型 #include 使用 vector 有两种不同的形式 即所谓的数组习惯和 STL 习惯 在数组习惯用法中 我 们模仿内置数组的用法 定义一个已知长度的 vector vector< int > ivec( 10 ); 这与如下定义一个包含十个元素的内置数组相似 int ia[ 10 ]; 我们可以用下标操作符访问 vector 的元素 与访问内置数组的元素的方式一样 例如 void simple_example() { const int elem_size = 10; vector< int > ivec( elem_size ); int ia[ elem_size ]; for ( int ix = 0; ix < elem_size; ++ix ) ia[ ix ] = ivec[ ix ]; // ... } 我们可以用 size()查询 vector 的大小 也可以用 empty()测试它是否为空 例如 void print_vector( vector ivec ) { if ( ivec.empty() ) return; for ( int ix = 0; ix < ivec.size(); ++ix ) cout << ivec[ ix ] << ' '; } vector的元素被初始化为与其类型相关的缺省值 算术和指针类型的缺省值是 0 对于 class 类型 缺省值可通过调用这类的缺省构造函数获得 关于缺省构造函数的介绍见 2.3 节 我们还可以为每个元素提供一个显式的初始值来完成初始化 例如 vector< int > ivec( 10, -1 ); 定义了 ivec 它包含十个 int 型的元素 每个元素都被初始化为-1 对于内置数组 我们可以显式地把数组的元素初始化为一组常量值 例如 int ia[ 6 ] = { -2, -1, 0, 1, 2, 1024 }; 我们不能用同样的方法显式地初始化 vector 但是 可以将 vector 初始化为一个已有数 组的全部或一部分 只需指定希望被用来初始化 vector 的数组的开始地址以及数组最末元素 的下一位置来实现 例如 // 把 ia 的 6 个元素拷贝到 ivec 中 vector< int > ivec( ia, ia+6 ); 被传递给 ivec 的两个指针标记了用来初始化对象的值的范围 第二个指针总是指向要被 拷贝的末元素的下一位置 标记出来的元素范围也可以是数组的一个子集 例如 101 第三章 C++数据类型 // 拷贝 3 个元素 ia[2], ia[3], ia[4] vector< int > ivec( &ia[ 2 ], &ia[ 5 ] ); 与内置数组不同 vector 可以被另一个 vector 初始化 或被赋给另一个 vector 例如 vector< string > svec; void init_and_assign() { // 用另一个 vector 初始化一个 vector vector< string > user_names( svec ); // ... // 把一个 vector 拷贝给另一个 vector svec = user_names; } 在 STL9中对 vector 的习惯用法完全不同 我们不是定义一个已知大小的 vector 而是定 义一个空 vector vector< string > text; 我们向 vector 中插入元素 而不再是索引元素 以及向元素赋值 例如 push_back()操 作 就是在 vector 的后面插入一个元素 下面的 while 循环从标准输入读入一个字符串序列 并每次将一个字符串插入到 vector 中 string word; while ( cin >> word ) { text.push_back( word ); // ... } 虽然我们仍可以用下标操作符来迭代访问元素 cout << "words read are: \n"; for ( int ix = 0; ix < text.size(); ++ix ) cout << text[ ix ] << ' '; cout << endl; 但是 更典型的做法是使用 vector 操作集中的 begin()和 end()所返回的迭代器 iterator 对 cout << "words read are: \n"; for ( vector::iterator it = text.begin(); it != text.end(); ++it ) cout << *it << ' '; cout << endl iterator是标准库中的类 它具有指针的功能 *it; 对迭代器解引用 并访问其指向的实际对象 ++it; 9 STL 表示标准模板库 Standard Template Library 在被纳入到标准 C++中之前 vector 与泛型算法是独立 库 STL 的一部分 见 MUSSER96 102 第三章 C++数据类型 向前移动迭代器 it 使其指向下一个元素 在第 6 章 我们将非常详细地讨论 iterator vector 和一般的 STL 习惯用法 注意 不要混用这两种习惯用法 例如 下面的定义 vector< int > ivec; 定义了一个空 vector 再写这样的语句 ivec[ 0 ] = 1024; 就是错误的 因为 ivec 还没有第一个元素 我们只能索引 vector 中已经存在的元素 size() 操作返回 vector 包含的元素的个数 类似地 当我们用一个给定的大小定义一个 vector 时 例如 vector ia( 10 ); 任何一个插入操作都将增加 vector 的大小 而不是覆盖掉某个现有的元素 这看起来好 像是很显然的 但是 下面的错误在初学者中并不少见 const int size = 7; int ia[ size ] = { 0, 1, 1, 2, 3, 5, 8 }; vector< int > ivec( size ); for ( int ix = 0; ix < size; ++ix ) ivec.push_back( ia[ ix ]); 程序结束时 ivec 包含 14 个元素 ia 的元素从第八个元素开始插入 另外 在 STL 习惯用法下 vector 的一个或多个元素可以被删除 我们将在第 6 章讨 论 练习 3.24 下列 vector 定义中 哪些是错误的 int ia[ 7 ] = { 0, 1, 1, 2, 3, 5, 8 }; (a) vector< vector< int > > ivec; (b) vector< int > ivec = { 0, 1, 1, 2, 3, 5, 8 }; (c) vector< int > ivec( ia, ia+7 ); (d) vector< string > svec = ivec; (e) vector< string > svec( 10, string( "null" )); 练习 3.25 已知下面的函数声明 bool is_equal( const int*ia, int ia_size, const vector &ivec ); 请实现下列行为 如果两个容器大小不同 则比较相同大小部分的元素 一旦某个元素 不相等 则返回 false 如果所有元素都相等 则返回 true 请用 iterator 迭代访问 vector 可 以以本节中的例子为模型 并且写一个 main()函数来测试 is_equal()函数103 第三章 C++数据类型 3.11 复数类型 复数 complex number 类是标准库的一部分 为了能够使用它 我们必须包含其相关 的头文件 #include 每个复数都有两部分 实数部分和虚数部分 虚数代表负数的平方根 这个术语是由笛 卡儿首创的 复数的一般表示法如下 2 + 3i 这里 2 代表实数部分 而 3i 表示虚数部分 这两部分合起来表示单个复数 复数对象的定义一般可以使用以下形式 // 纯虚数 0 + 7i complex< double > purei( 0, 7 ); // 虚数部分缺省为 0 3 + 0i complex< float > real_num( 3 ); // 实部和虚部均缺省为 0 0 + 0i complex< long double > zero; // 用另一个复数对象来初始化一个复数对象 complex< double > purei2( purei ); 这里 复数对象有 float double 或 long double 几种表示 我们也可以声明复数对象的数 组 complex< double > conjugate[ 2 ] = { complex< double >( 2, 3 ), complex< double >( 2, -3 ) }; 我们也可以声明指针或引用 complex< double > *ptr = &conjugate[0]; complex< double > &ref = *ptr; 复数支持加 减 乘 除和相等比较 另外 它也支持对实部和虚部的访问 这些操作 将在 4.6 节中详细介绍 3.12 typedef 名字 typedef机制为我们提供了一种通用的类型定义设施 可以用来为内置的或用户定义的数 据类型引入助记符号 例如 typedef double wages; typedef vector vec_int; typedef vec_int test_scores; typedef bool in_attendance; 104 第三章 C++数据类型 typedef int *Pint; 这些 typedef 名字在程序中可被用作类型标识符 // double hourly, weekly; wages hourly, weekly; // vector vec1( 10 ); vec_int vec1( 10 ); // vector test0( class_size ); const int class_size = 34; test_scores test0( class_size ); // vector< bool > attendance; vector< in_attendance > attendance( class_size ); // int *table[ 10 ]; Pint table[ 10 ]; typedef定义以关键字 typedef 开始 后面是数据类型和标识符 这里的标识符即 typedef 名字 它并没有引入一种新的类型 而只是为现有类型引入了一个助记符号 typedef 名字对 以出现在任何类型名能够出现的地方 typedef名字可以被用作程序文档的辅助说明 它也能够降低声明的复杂度 例如 在典 型情况下 typedef 名字可以用来增强 复杂模板声明的定义 的可读性 见 3.14 节中的例 子 增强 指向函数的指针 将在 7.9 节中讨论 以及 指向类的成员函数的指针 将 在 13.6 节中讨论 的可读性 下面是一个几乎所有人刚开始时都会答错的问题 错误在于将 typedef 当作宏扩展 已 知下面的 typedef typedef char *cstring; 在以下声明中 cstr 的类型是什么 extern const cstring cstr; 第一个回答差不多都是 const char *cstr 即指向 const 字符的指针 但是 这是不正确的 const 修饰 cstr 的类型 cstr 是一个指 针 因此 这个定义声明了 cstr 是一个指向字符的 const 指针 见 3.5 节关于 const 指针类型 的讨论 char *const cstr; 3.13 volatile 限定修饰符 当一个对象的值可能会在编译器的控制或监测之外被改变时 例如一个被系统时钟更新 的变量 那么该对象应该声明成 volatile 因此 编译器执行的某些例行优化行为不能应用在 已指定为 volatile 的对象上 105 第三章 C++数据类型 volatile 限定修饰符的用法同 const 非常相似——都是作为类型的附加修饰符 例如 volatile int display_register; volatile Task *curr_task; volatile int ixa[ max_size ]; volatile Screen bitmap_buf; display_register是一个 int 型的 volatile 对象 curr_task 是一个指向 volatile 的 Task 类对 象的指针 ixa 是一个 volatile 的整型数组 数组的每个元素都被认为是 volatile 的 bitmap_buf 是一个 volatile 的 Screen 类对象 它的每个数据成员都被视为 volatile 的 volatile 修饰符的主要目的是提示编译器 该对象的值可能在编译器未监测到的情况下被 改变 因此编译器不能武断地对引用这些对象的代码作优化处理 3.14 pair 类型 pair类也是标准库的一部分 它使得我们可以在单个对象内部把相同类型或不同类型的 两个值关联起来 为了使用 pair 类 我们必须包含下面的头文件 #include 例如 pair< string, string > author( "James", "Joyce" ); 创建了一个 pair 对象 author 它包含两个字符串 分别被初始化为 James 和 Joyce 我们可以用成员访问符号 member access notation 访问 pair 中的单个元素 它们的名 字为 first 和 second 例如 string firstBook; if ( author.first == "James" && author.second == "Joyce" ) firstBook = "Stephen Hero"; 如果我们希望定义大量相同 pair 类型的对象 那么最方便的做法就是用 typedef 如下所 示 typedef pair< string, string > Authors; Authors proust( "marcel", "proust" ); Authors joyce( "james", "joyce" ); Authors musil( "robert", "musil" ); 下面是第二个 pair 一个元素持有对象的名字 另一个元素持有指向其符号表入口的指 针 // 前向声明(forward declaration) class EntrySlot; extern EntrySlot* look_up( string ); typedef pair< string, EntrySlot* > SymbolEntry; SymbolEntry current_entry( "author", look_up( "author" )); 106 第三章 C++数据类型 // ... if ( EntrySlot *it = look_up( "editor" )) { current_entry.first = "editor"; current_entry.second = it; } 我们将在第 6 章讨论标准库容器类型 以及第 12 章讨论标准库泛型算法的时候 再次看 到 pair 类型 3.15 类类型 类机制支持新类型的设计 如本章讨论的基于对象的 string vector complex pair 类型 以及第 1 章介绍的面向对象的 iostream 类层次结构 在第 2 章中 我们通过一个 Array 类抽 象的实现和进化过程 将支持面向对象的与基于对象的类设计的某本概念和机制快速浏览了 一遍 在本节中 我们将简要地介绍一个基于对象的 String 类抽象的设计与实现 它将得益 于我们前面给出的 对 C 风格字符串以及标准库 string 类型的讨论 这个实现将着重说明 C++ 对操作符重载 operator overloading 的支持 2.3 节曾简单介绍过这方面的知识 从第 13 章到第 15 章将详细介绍类 我们在本书的开始部分先介绍类的某些方面 是为了使我们能够 在本书 13 章之前就可以提供一些更有意义的 并且用到了类的例子 初次阅读本书的读者可 跳过本节 在对后面章节有了更多的了解后 再回头来看 现在我们对 String 类应该做些什么已经很清楚 我们需要支持 String 对象的初始化和赋 值 包括用字符串文字 C 风格字符串 以及另外一个 String 对象进行初始化或者赋值 我 们将通过特定的构造函数以及类特定的 赋值操作符实例来实现这样的功能 我们需要支持用索引访问 String 中的单个字符 以便与 C 风格字符串和标准库 string 类 型具有相同的方式 我们将提供一个类特定的下标操作符实例来做到这一点 另外 我们还想支持这样一些操作 如确定 String 长度的 size() 两个 String 对象的相等 比较 或者 String 同 C 风格字符串的比较 读写一个 String 对象等等 我们将提供等于 iostream 输入 iostream 输出操作符的实例 以实现后两个操作 最后 我们还需要访问底层的 C 风 格字符串 类的定义由关键字 class 开始 后面是一个标识符 该标识符也被用作类的类型指示符 如 complex vector 及 Array 等等 一般地 一个类包括公有的 public 操作部分和私有的 private 数据部分 这些操作被称为该类的成员函数 member function 或方法 method 它们定义了类的公有接口 public interface ——即 用户可以在该类对象上执行的操作的 集合 我们的 String 类的私有数据包括 _string 一个指向动态分配的字符数组的 char*类型 的指针 和_size 记录 String 中字符串长度的 int 型变量 下面是我们的定义 #include class String; * 这里的 类特定的 即 class-specific 是指相应的操作符属于 String 这个类 也就是说与 String 相关联 而不是系统全局缺省的操作符实例 107 第三章 C++数据类型 istream& operator>>( istream&, String& ); ostream& operator<<( ostream&, const String& ); class String { public: // 一组重载的构造函数 // 提供自动初始化功能 // String str1; // String() // String str2( "literal" ); // String( const char* ); // String str3( str2 ); // String( const String& ); String(); String( const char* ); String( const String& ); // 析构函数 自动析构 ~String(); // 一组重载的赋值操作符 // str1 = str2 // str3 = "a string literal" String& operator=( const String& ); String& operator=( const char* ); // 一组重载的等于操作符 // str1 == str2; // str3 == a string literal ; bool operator==( const String& ); bool operator==( const char* ); // 重载的下标操作符 // str1[ 0 ] = str2[ 0 ]; char& operator[]( int ); // 成员访问函数 int size() { return _size; } char* c_str() { return _string; } private: int _size; char *_string; }; String类定义了三个构造函数 正如在 2.3 节中简要讨论的那样 重载函数机制允许同 函数名或操作符引用到多个实例 只要通过参数表能区分开每个实例就行 我们的三个构造 函数形成了一个有效的重载函数集合 首先由参数个数 然后由参数类型来区分它们 第一 个构造函数 String(); 被称做缺省构造函数 因为它不需要任何显式的初始值 当我们写如下语句时 108 第三章 C++数据类型 String str1; 缺省构造函数将被应用到 str1 上 另外两个 String 构造函数都有一个参数 当我们写如下语句时 String str2( "a string literal" ); 根据参数类型 构造函数 String( const char* ); 被应用在 str2 上 类似地 当我们写如下语句时 String str3( str2 ); 构造函数 String( const String& ); 被应用在 str3 上——这是根据被传递给构造函数的参数类型来判断的 这种构造函数被 称为拷贝构造函数 copy constructor 因为它用另一个对象的拷贝来初始化一个对象 当 我们写如下语句时 String str4( 1024 ); 实参的类型与构造函数集期望的参数类型都不匹配 因此 str4 的定义导致一个编译错 误 被重载的操作符采用下面的一般形式 return_type operator op ( parameter_list ); 这里的 operator 是关键字 op 是一个预定义的操作符 如 + = == [] 等等 第 15 章有精确的规则 下面的声明 char& operator[]( int ); 声明了一个下标操作符的重载实例 它带有一个 int 型的参数 返回指向 char 的引用 重载的操作符还可以被重载 只要每个实例的参数表能够被区分开即可 例如 我们为 String 类提供了两个不同的赋值与等于操作符的实例 有名字的成员函数可以通过成员访问符号来调用 例如 已知下列 String 定义 String object( "Danny" ); String *ptr = new String( "Anna" ); String array[2]; 我们可以如下调用成员函数 size() 它们分别返回长度值 5 4 和 0 一会儿我们会看到 String 类的实现 vector sizes( 3 ); // 针对对象的点成员访问符号 . // object 的长度为 5 sizes[ 0 ] = object.size(); // 针对指针的箭头成员访问符号-> // ptr 的长度为 4 109 第三章 C++数据类型 sizes[ 1 ] = ptr->size(); // 再次使用点成员访问符号 // array[0] 的长度为 0 sizes[ 2 ] = array[0].size(); 被重载的操作符也可以直接应用在类对象上 例如 String name1( "Yadie" ); String name2( "Yodie" ); // 应用 bool operator==(const String&) if ( name1 == name2 ) return; else // 应用 String& operator=( const String& ) name1 = name2; 类的成员函数可以被定义在类的定义中 也可以定义在外面 例如 size()和 c_str() 都是在 String 类的定义中被定义的 在类定义之外定义的成员函数不但要告诉编译器它们 的名字 返回类型 参数表 而且还要说明它们所属的类 我们应该把成员函数的定义放到 一个程序文本文件中 例如 String.C 并且把含有该类定义的头文件 本例中为 String.h 包含进来 例如 // 放在程序文本文件中: String.C // 包含 String 类的定义 #include "String.h" // 包含 strcmp()函数的声明 // cstring 是标准 C 库的头文件 #include bool // 返回类型 String:: // 说明这是 String 类的一个成员 operator== // 函数的名字: 等于操作符 (const String &rhs) // 参数列表 { if ( _size != rhs._size ) return false; return strcmp( _string, rhs._string ) ? false : true; } strcmp()是 C 标准库函数 它比较两个 C 风格的字符串 如果相等则退回 0 否则返回非 0 条件操作符 ?: 测试问号前面的条件 如果为 true 选择问号与冒号之间的表达式 如 果为 false 则选择冒号后面的表达式 在本例中 如果 strcmp()返回非 0 值 条件操作符返 回 false 否则返回 true 4.7 节将详细讨论条件操作符 因为等于操作符是个可能要频繁调用的小函数 因此把它声明成内联 inline 函数是个 好办法 内联函数在每个调用点上被展开 因此 这样做可以消除函数调用相关的额外消耗 只要该函数被调用足够多次 7.6 节将详细介绍内联函数 内联函数就能够显著地提高性 能 在类定义内部定义的成员函数 如 size() 在缺省情况下被设置为 inline 在类外而定义 的成员函数必须显式地声明为 inline 110 第三章 C++数据类型 inline bool String::operator==(const String &rhs) { // 如前 } 在类体外定义的内联成员函数 应该被包含在含有该类定义的头文件中 我们在重新定 义了等于操作符之后 应当把它的定义从 String.C 移到 String.h 中 下面是比较 String 对象和 C 风格字符串的等于操作符 它也被定义成内联函数 因而被 放在 String.h 头文件中 inline bool String::operator==(const char *s) { return strcmp( _string, s ) ? false : true; } 构造函数的名字与类名相同 我们不能在它的声明或构造函数体中指定返回值 它的一 个或多个实例都可以被声明成 inline #include // 缺省构造函数 inline String::String() { _size = 0; _string = 0; } inline String::String( const char *str ) { if ( ! str ) { _size = 0; _string = 0; } else { _size = strlen( str ); _string = new char[ _size + 1 ]; strcpy( _string, str ); } } // 拷贝构造函数 inline String::String( const String &rhs ) { _size = rhs._size; if ( ! rhs._string ) _string = 0; else { _string = new char[ _size + 1 ]; strcpy( _string, rhs._string ); } } 因为我们用 new 表达式动态地分配内存来保留字符串 所以当不再需要该字符串对象的 时候 必须用 delete 表达式释放该内存区 这可以通过定义类的析构函数自动实现 把 delete 111 第三章 C++数据类型 表达式放在构析函数中 如果类的析构函数存在 那么在每个类的生命期结束时它会被自动 调用 第 8 章将解释一个对象的三种可能的生命期 析构函数由类名前面加一个波浪号 ~ 来标识 下面是 String 类的析构函数的定义 inline String::~String() { delete [] _string; } 两个被重载的赋值操作符引用了一个特殊的关键字 this 当我们写如下代码时 String name1( "orville" ), name2( "wilbur" ); name1 = "Orville Wright"; 在赋值操作符中 this 指向 name1 更一般的情况下 在类成员函数中 this 指针被自动设置为指向左侧的类对象 我们通过 这对象调用这个成员函数 当我们写如下代码时 ptr->size(); obj[ 1024 ]; 在 size()中 this 指针指向 ptr 在下标操作符中 this 指针指向 obj 当我们写*this 时 访问的是 this 所指的实际对象 13.4 节将详细讨论 this 指针 inline String& String::operator=( const char *s ) { if ( ! s ) { _size = 0; delete [] _string; _string = 0; } else { _size = strlen( s ); delete [] _string; _string = new char[ _size + 1 ]; strcpy( _string, s ); } return *this; } 当我们把一个类对象拷贝给另一个时 最常犯的错误是忘了先测试这两个类对象是否确 实是同一个对象 当一个或两个对象都是通过解除一个指针的引用而来时 这个错误最经常 发生 此时 this 指针将再次发挥作用 以支持这种测试 例如 inline String& String::operator=( const String &rhs ) { // 在表达式 name1 = *pointer_to_string 中, // this 指向 name1, // rhs 代表*pointer_to_string. if ( this != &rhs ) { 下面是完整的实现代码 inline String& String::operator=( const String &rhs ) { if ( this != &rhs ) 112 第三章 C++数据类型 { delete [] _string; _size = rhs._size; if ( ! rhs._string ) _string = 0; else { _string = new char[ _size + 1 ]; strcpy( _string, rhs._string ); } } return *this; } 下标操作符几乎与 2.3 节中 Array 类的实现相同 #include inline char& String::operator[]( int elem ) { assert( elem >= 0 && elem < _size ); return _string[ elem ]; } 输入操作符和输出操作符是作为非成员函数实现的 原因将在 15.2 节中讨论 20.4 节 与 20.5 节将对重载 iostream 输入和输出操作符进行详细讨论 我们的输入操作符最多读入 4095 个字符 setw()是一个预定义的 iostream 操纵符 它读入的字符数最多为传递给它的参 数减 1 因此 我们可以保证不会溢出 inBuf 字符数组 为了使用它 我们必须包含 iomanip 头文件 第 20 章将详细讨论 setw() #include inline istream& operator>>( istream &io, String &s ) { // 人工限制最多 4096 个字符 const int limit_string_size = 4096; char inBuf[ limit_string_size ]; // setw()是 iostream 库的一部分 // 限制被读取的字符个数为 limit_string_size-1 io >> setw( limit_string_size ) >> inBuf; s = inBuf; // String::operator=( const char* ); return io; } 为了显示 String 输出操作符需要访问内部的 char 表示 但是 因为它不是类的成员函 数 所以它没有访问_string 的权限 有两种可能的解决方案 一种是给输出操作符赋予一个 特殊的访问许可 把它声明成类的友元 friend ——我们将在 15.2 节中看到 第二种方法 是提供一个内联的访问函数——在本例中为 c_str() 这是以标准库 string 类提供的解决方案 为模型的 下面是实现 113 第三章 C++数据类型 inline ostream& operator<<( ostream& os, String &s ) { return os << s.c_str(); } 下面的小程序练习了 String 类的实现 它从标准输入读入一个 String 序列 然后再顺序 访问 String 并记录出现的元音字母 #include #include "String.h" int main() { int aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0, theCnt = 0, itCnt = 0, wdCnt = 0, notVowel = 0; // 为了使用 operator==( const char* ) // 我们并不定义 The( "The" )和 It( "It" ) String buf, the( "the" ), it( "it" ); // 调用 operator>>( istream&, String& ) while ( cin >> buf ) { ++wdCnt; // 调用 operator<<( ostream&, const String& ) cout << buf << ' '; if ( wdCnt % 12 == 0 ) cout << endl; // 调用 String::operator==(const String&) and // String::operator==( const char* ); if ( buf == the || buf == "The" ) ++theCnt; else if ( buf == it || buf == "It" ) ++itCnt; // 调用 String::size() for ( int ix = 0; ix < buf.size(); ++ix ) { // 调用 String::operator[](int) switch( buf[ ix ] ) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; default: ++notVowel; break; } } } // 调用 operator<<( ostream&, const String& ) cout << "\n\n" 114 第三章 C++数据类型 << "Words read: " << wdCnt << "\n\n" << "the/The: " << theCnt << '\n' << "it/It: " << itCnt << "\n\n" << "non-vowels read: " << notVowel << "\n\n" << "a: " << aCnt << '\n' << "e: " << eCnt << '\n' << "i: " << iCnt << '\n' << "o: " << oCnt << '\n' << "u: " << uCnt << endl; } 程序的输入是 Stan 写的儿童故事中的一段话 在第 6 章我们会再次看到 编译并执行 程序 它产生如下输出 Alice Emma has long flowing red hair. Her Daddy says when the wind blows through her hair, it looks almost alive, like a fiery bird in flight. A beautiful fiery bird, he tells her, magical but untamed. "Daddy, shush, there is no such thing," she tells him, at the same time wanting him to tell her more. Shyly, she asks, "I mean, Daddy, is there?" Words read: 65 the/The: 2 it/It: 1 non-vowels read: 190 a: 22 e: 30 i: 24 o: 10 u: 7 练习 3.26 在 String 类的构造函数和赋值操作符的实现中 有大量的重复代码 请使用 2.3 节中展 示的模型 把这些公共代码抽取成独立的私有成员函数 用它们重新实现构造函数和赋值操 作符 并重新运行程序以确保其正确 练习 3.27 修改程序 使其也能够记下辅音字母 b d f s 和 t 的个数 练习 3.28 实现能够返回 String 中某个字符出现次数的成员函数 声明如下 class String { public: // ... int count( char ch ) const; // ... }; 115 第三章 C++数据类型 练习 3.29 实现一个成员操作符函数 它能把一个 String 与另一个连接起来 并返回一个新的 String 声明如下 class String { public: // ... String operator+( const 2String &rhs ) const; // ... }; 4 表达式 在第3章中 我们已经介绍了内置类型以及标准库支持的类型 在本章中 我们将 了解预定义的操作符 如加 减 赋值 相等测试等等 我们利用这些操作符来操 纵数据 然后 再讨论一下操作符的优先级问题 例如 给出表达式 3+4*5 结果 总是 23 而不是 35 这是因为乘法运算符的优先级比较高 最后 我们还将讨论 对象类型之间的显式和隐式转换 例如 在表达式 3+0.7 中 整数 3 总是在加法执 行前先被转换成浮点数 4.1 什么是表达式 表达式由一个或多个操作数 operand 构成 最简单的表达式由一个文字常量或一个对 象构成 一般地 表达式的结果是操作数的右值 例如 下面是三个表达式 void mumble() { 3.14159; "melancholia"; upperBound; } 3.14159 的结果是 3.14159 它的类型是 double melancholia 的结果是字符串第一个 元素的内存地址 它的类型是 const char* upperBound 的结果是与其相关联的值 类型由它 的定义来决定 在更一般的情况下 表达式由一个或多个操作数 以及应用在这些操作数上的操作构成 例如 下面都是表达式 我们省略了对象的定义 根据操作数的类型 自然会有适当的操作 被应用在这些操作数上 salary + raise ivec[ size/2 ] * delta first_name + " " + last_name 应用在操作数上的操作由操作符 operator 表示 例如 在第一个表达式中 浮点加法 操作符被施加在操作数 salary 和 raise 上 在第二个表达式中 操作数 size 被 2 除 其结果被 117 第四章 表达式 用来索引整型数组 ivec 然后这个值再被乘以操作数 delta 在第三个表达式中 两个 string 操作数与一个字符串文字连接起来形成一个新的 string 这个动作是通过标准库 string 类定义 的加法操作符实例来实现 作用在一个操作数上的操作符被称为一元操作符 unary operator 比如取地址操作符 & 和解引用操作符 * 作用在两个操作数上的操作符 比如加法操作符 减法操作符 被称为二元操作符 binary operator 有些操作符既能表示一元操作也能表示二元操作 确 切地说 是相同的符号用来表示两个不同的操作 例如 *ptr 表示一元解引用操作符 它返回 ptr 指向的存储区存储的值 而 var1 *var2 则表示二元乘法操作符 它计算两个操作数 var1 和 var2 相乘的结果 表达式的计算是指执行一个或多个操作 最后产生一个结果 除非特别声明 一般来说 表达式的结果是个右值 算术表达式结果的类型由操作数的类型来决定 当存在多种数据类 型时 编译器将根据一套预定义的类型转换规则集进行类型转换 4.14 节将详细介绍类型转 换 当两个或两个以上的操作符被组合起来的时候 这样的表达式被称为复合表达式 compound expression 例如 下面表达式的目的是判断指针 ptr 是否指向一个对象 如果 它的值不是 0 则它指向一个对象 以及指向的对象是否有一个非零值10 ptr != 0 && *ptr != 0 整个表达式由三个子表达式构成 ptr 是否为 0 的不等于测试 ptr 的解引用 以及解引 用的结果是否为 0 的不等于测试 如果定义 ptr 如下 int ival = 1024; int *ptr = &ival; 解引用子表达式的结果为 1024 两个不等于测试子表达式的结果都是 true 整个表达式 的结果也是 true ptr 没有被设置为 0 并且它指向的对象也没有被设置为 0 &&操作符被 称为逻辑与操作符 或者逻辑 AND 操作符 如果它左右两边的子表达式都为 true 则它的值 为 true 否则为 false 如果我们进一步仔细地看一下该复合表达式 则会注意到 是否能够成功地计算表达式 要取决于子表达式的计算顺序 例如 如果表达式的第二部分先被计算 即 如果在确信指 针 ptr 不为 0 之前解引用这个指针 那么 当 ptr 被置为 0 时 程序就可能在运行时刻失败 对于逻辑与操作符 C++严格定义了子表达式的计算顺序 如果左边的子表达式的值为 false 则不计算右边的子表达式 因此上面的错误就不会发生 在实际情况下 子表达式的计算顺序常常是 C 或 C++初学者出错的根源 这样的错误很 难查找 因为这种错误不是凭直觉就能看出来的 除非我们了解子表达式的计算规则 一般 10 显式地与 0测试是可选的 不是必需的 所以下面的表达式与它是等价的 ptr &&*ptr 而且这样的表达式更符合实际 C++程序的习惯 118 第四章 表达式 来说 子表达式的计算顺序由操作符的优先级 precedence 和结合性 associativity 来决 定 我们将在了解了 C++支持哪些操作符之后 在 4.13 节中详细地讨论这个问题 以下部分 我们将按照一般习惯上的顺序来讨论 C++预定义的操作符 表格 4-1 算术操作符 操作符 功能 用法 * 乘 expr * expr / 除 expr / expr % 求余 expr % expr + 加 expr + expr - 减 expr - expr 4.2 算术操作符 两个整数相除的结果是整数 如果商含有小数部分 将被截掉 例如 int ival1 = 21 / 6; int ival2 = 21 / 7; 结果是 ival1 和 ival2 都被 3 初始化 %操作符计算两个数相除的余数 第一个数被第二个数除 该操作符只能被应用在整值 类型 char short int 和 long 的操作数上 当两个操作数都是正数时 结果为正 但是 如果有一个 或两个 操作数为负 余数的符号则取决于机器 因此 移植性无法保证 % 操作符被称作取模 modulus 或求余 remainder 操作符 3.14 % 3; // 编译时刻错误: 浮点操作数 21 % 6; // ok: 结果是 3 21 % 7; // ok: 结果是 0 21 % -5; // 机器相关: 结果为 -1 或 1 int ival = 1024; double dval = 3.14159; ival % 12; // ok: 返回值在 0 和 11 之间 ival % dval; // 编译时刻错误: 浮点操作数 在某些实例中 算术表达式的计算会导致不正确或未定义的值 这些情况被称为算术异 常 arithmetic exception 但是不会导致抛出实际的异常 算术异常要归咎于算术的自然 本质 比如除以 0 或归咎于计算机的自然本质——比如溢出 overflow 指结果值超出了 被赋值对象的类型长度 例如 8 位的 char 根据它有符号还是无符号 它可以包含最大数 127 或 255 下面的乘法向一个 char 赋值 256 因而导致了溢出 #include 119 第四章 表达式 int main() { char byte_value = 32; int ival = 8; // overflow of byte_value's available memory byte_value = ival * byte_value; cout << "byte_value: " << static_cast(byte_value) << endl; } 表示 256 需要 9 位 因而向 byte_value 赋值 256 导致了与其相关联的内存的溢出 byte_value 包含的实际值是未定义的 所以在执行时就可能会引起问题 例如 在 SGI 工作 站上 byte_value 被设置为 0 当用表达式 cout << "byte_value: " << byte_value << endl; 试图输出它时 程序输出结果如下 byte_value: 经过几分钟的迷惑之后 我们意识到 在 ASCII 码集中 0 代表空 null 字符 所以 什么也不输出 如下特殊表达式 static_cast ( byte_value ) 称为显式类型转换 explicit type conversion 或强制类型转换 cast 强制转换使编译 器把一个对象 或表达式 从它当前的类型转换成程序员指定的类型 在这种情况下 我们 把 byte_value 转换成一个 int 型的对象 现在程序输出 byte_value: 0 在本例中 我们改变的不是与 byte_value 相关的值 而是它被输出操作符解释的方式 当它被当作 char 型时 它的值被映射到相关联的 ASCII 表示上 例如 12 表示换行 97 表 示小写 a 0 代表空字符等 输出的是它所代表的字符 而不是它的值 当它被看作 int 型 时 它的值被直接输出 我们将在 4.14 节讨论类型转换 我们的叙述需要中断一下 转而讨论类型转换以及 byte_value 的输出失败 这有点类似 于我们常遇到的情况——即 当程序不能如我们期望的那样运行时 就需要把程序设计任务 先放下 去查看一下出了什么问题 那些看起来比较晦涩 并不有趣的语言要素 比如数据 类型的长度等 在实践中有时候会影响我们所写的程序 例如 发生在 byte_value 上的溢出 错误 就不会被语言捕捉到 因为它涉及到每个计算的运行时刻检查 从性能的角度来看 这是不切合实际的 但是我们必须知道 它是有发生的可能性的 标准 C++头文件 limits 提供了与内置类型表示有关的信息 例如一个类型能表示的最大 值和最小值 另外 C++编译系统也提供了标准 C 头文件 climits 和 cfloat 它们定义了提供 类似信息的预处理器宏 怎样使用这些头文件来防止溢出 overflow 和下溢 underflow 请参见 PLAUGER92 的第 4 章和第 5 章 浮点数的算术运算还有一个精度问题 在计算机中 当它表示一个数值时 只有固定 的数位可以使用 当一个数值被修改 以便适合 用来表示该数的 float double 或 long double 类型 时 就会发生浮点舍入 roundoff 浮点数加法 乘法和减法的结果精度受到底层 120 第四章 表达式 数据类型的固有精度的影响 关于算术运算舍入错误的详细讨论 请参见 SHAMPINE97 练习 4.1 下列两个除法表达式的主要区别是什么 double dval1 = 10.0, dval2 = 3.0; int ival1 = 10, ival2 = 3; dval1 / dval2; ival1 / ival2; 练习 4.2 给出一个有序对象 可用什么操作符来判定它是奇数还是偶数 写出表达式 练习 4.3 在你的系统中找到并检查 C++头文件 limits 和标准 C 头文件 climits 以及 cfloat 4.3 等于 关系和逻辑操作符 表格 4.2 等于 关系以及逻辑操作符 操作符 功能 用法 ! 逻辑非 !expr < 小于 expr < expr <= 小于等于 expr <= expr > 大于 expr > expr >= 大于等于 expr >= expr == 等于 expr == expr != 不等于 expr != expr && 逻辑与 expr && expr || 逻辑或 expr || expr 注 这些操作符的结果是 bool 类型 等于 关系和逻辑操作符的计算结果是布尔常量 true 或 false 如果这些操作符用在要求 整数值的上下文环境中 它们的结果将被提升成 1 true 或 0 false 例如 在下面的代 码段中 我们准备统计 vector 中小于某个给定值的元素的个数 为了完成它 我们把小于操 121 第四章 表达式 作符的结果加到一个记录元素个数的计数器上 +=操作符是一个简化记号 它表示把右边 的表达式加到左边的操作数的当前值上 我们将在 4.4 节讨论复合赋值操作符 int elem_cnt = 0; vector::iterator iter = ivec.begin(); while ( iter != ivec.end() ) { // 同下: elem_cnt = elem_cnt + (*iter < some_value) // *iter < some _value 的布尔值 // 将提升为 1 或 0 elem_cnt += *iter < some_value; ++iter; } 只有当逻辑与 && 操作符的两个操作数都为 true 时 结果值才会是 true 对于逻辑 或 || 操作符 只要两个操作数之一为 true 它的值就为 true 这些操作数被保证按从左 至右的顺序计算 只要能够得到表达式的值 true 或 false 运算就会结束 给定以下形 式 expr1 && expr2 expr1 || expr2 如果下列条件有一个满足 在逻辑与表达式中 expr1 的计算结果为 false 在逻辑或表达式中 expr1 的计算结果为 true 则保证不会计算 expr2 对于逻辑与操作符 一个很有价值的用法是 在某些使 expr2 的计算变得危险的边界条 件出现前 先使 expr1 计算为 false 例如 while ( ptr != 0 && ptr->value < upperBound && ptr->value >= 0 && notFound( ia[ ptr->value ] )) { ... } 值为 0 的指针不指向任何对象 把成员访问操作符应用在 0 值指针上总会引起麻烦 第 一个逻辑与操作符保证不会发生这种事情 数组下标越界是同样麻烦的事情 第二个和第三 个操作数保证不会发生这种可能 只有当前三个操作数计算的结果全为 true 的 最后一个操 作数才会安全地被计算 对于逻辑非操作符 ! 来说 当它的操作数为 false 或 0 时其值为 true 否则为 false bool found = false; // 当未找到所需项 // 且 ptr 仍要寻址某对象时 while ( ! found && ptr ) { found = lookup( *ptr ); ++ptr; } 如下的子表达式 122 第四章 表达式 ! found 只要 found 等于 false 其值就为 true 它是如下显式测试的简短表示 // 含义等同于!found found == false 类似地 下面的测试 if ( found ) 是如下显式测试表达式的简短表示 if ( found == true ) 虽然二元关系操作符 小于或等于操作符 的用法十分简单 但是我们必须知道其潜在 的缺点 左右操作数的计算顺序在标准 C 和 C++中都是未定义的 因此计算过程必须是与顺 序无关的 例如 下列表达式 // 喔! C++语言本身并没有定义计算顺序 if ( ia[ index++ ] < ia[ index ] ) // 交换元素 程序员假设左边的操作数先计算 因此 比较 ia[0]是否小于 ia[1] 但是 C 或 C++语言 并不保证从左到右的计算顺序 实际上的实现可能是先计算右边的操作数 在这种情况下 ia[0]与它自已作比较 而 ia[1]从来没有被计算 安全且可移植的实现如下 if ( ia[ index ] < ia[ index+1 ] ) // 交换元素 ++index; 第二个潜在的缺点如下所述 我们的目的是判断 ival jval 和 kval 是否各不相同 你能 看出有什么不对吗 // 喔! 这样做并不能判断 3 个值是否不相等 if ( ival != jval != kval ) // 省略其他代码 正如我们所实现的 相关联的三个值 0 1 和 0 可使我们的表达式值为 true 原因是 第 一个不等于表达式的左操作数为 true 或 false 它是第一个表达式的结果——即 kval 被测试 是否与转换来的 0 或 1 不相等 要实现我们的测试目的 我们必须重写表达式如下 if ( ival != jval && ival != kval && jval != kval ) // 省略其他代码 练习 4.4 下列哪个表达式不正确或不可移植 为什么 怎样改正 注意 在这些例子中对象的 类型并不重要 (a) ptr->ival != 0 (b) ival != jval < kval (c) ptr != 0 && *ptr++ (d) ival++ && ival (e) vec[ ival++ ] <= vec[ ival ]; 123 第四章 表达式 练习 4.5 二元操作符的计算顺序未定义 因而允许编译器自由地提供可选的实现 这是在 有效 的实现 和 程序员使用的语言存在潜在缺点 之间的一种折衷 你认为这是一种可接受的 折衷吗 为什么 4.4 赋值操作符 初始化过程为对象提供了初值 例如 int ival = 1024; int *pi = 0; 而赋值则是用一个新值覆盖对象的当前值 例如 ival = 2048; pi = &ival; 赋值和初始化有时候会被混淆 因为它们都使用同一个操作符 = 一个对象只能被 初始化一次 也就是在它被定义的时候 但是在程序中可以被赋值多次 当我们把不同类型的表达式赋值给一个对象时 会发生什么事情呢 例如 ival = 3.1415926; // ok? 规则是 右边表达式的类型必须与左边被赋值对象的类型完全匹配 在本例中 ival 的 类型是 int 而文字常量 3.14159 是 double 类型 这个赋值是错误的吗 不 编译器会试着隐 式地将右操作数的类型转换成被赋值对象的类型 如果这种类型转换是有可能的 则编译器 会悄悄进行 如果涉及到精度损失 如 double 转换成 int 通常会给出一个警告 在本例 中 3.1415926 被转换成 int 型文字常量 3 这正是被赋给 ival 的值 如果不可能进行隐式的类型转换 那么赋值操作被标记为编译时刻错误 例如 下面的 赋值将导致编译错误 因为从 int 型的值到 int* 型没有隐式的类型转换 pi = ival; // error C++语言支持的隐式类型转换集合将在 4.14 节讨论 赋值操作符的左操作数必须是左值——即 它必须有一个相关联的 可写的地址值 下 面是一个明显的非左值赋值的例子 1024 = ival; // 错误 下面是一种可能的解决方案 int value = 1024; value = ival; // ok 然而 在某些情况下 只有左值还不够 例如 已知下列对象定义 const int array_size = 8; int ia[ array_size ] = { 0, 1, 2, 2, 3, 5, 8, 13 }; int *pia = ia; 以下赋值操作 124 第四章 表达式 array_size = 512; // 错误 是非法的 尽管 array_size 是一个左值 但是 array_size 的 const 定义使它的地址值个 可写 类似地 以下赋值操作 ia = pia; // 错误 是非法的 尽管 ia 是个左值 但是数组对象本身不能被赋值 只有它包含的元素才能被 赋值 而赋值操作 pia + 2 = 1; // 错误 也是非法的 尽管 pia + 2 计算出 ia[2]的地址 但是结果不是一个可写的地址值 然而 如果把解引用操作符应用在地址值上 如 *(pia + 2) = 1; // ok 则赋值就是正确的 解引用操作符表示赋值是对 pia+2 指向的对象的 赋值的结果是实际上被放在左操作数相关内存中的值 例如如下赋值的结果是 0 ival = 0; 而如下赋值的结果是 3 ival = 3.14159; 两者都是 int 型 因此 赋值表达式可以被当作一个子表达式 例如 下面的 while 循环 extern char next_char(); int main() { char ch = next_char(); while ( ch != '\n' ) { // 省略代码 ch = next_char(); } // ... } 可以被改写成 extern char next_char(); int main() { char ch; while (( ch = next_char() ) != '\n' ) { // do something ... } // ... } 外加的小括号是必需的 因为赋值操作符的优先级低于不等于操作符 优先级决定了表 125 第四章 表达式 达式中计算的顺序 优先级高的先计算 没有小括号 那么不等于测试 next_char() != '\n' 将先被计算 然后 ch 才被赋值为 false 或 true 即测试 next_char()是否不等于换行符的 结果——显然 这不是我们想要的 我们将在 4.13 节详细了解优先级 类似地 赋值操作符也可以被连接在一起 只要每个被赋值的操作数都是相同的数据类 型 例如 在下列代码中 int main() { int ival, jval; ival = jval = 0; // ok: 两个都被赋为 0 // ... } ival和 jval 都被赋为 0 但是 以下代码是非法的 因为 ival 和 pval 是不同类型的对象 尽管 0 可以被赋给它们中的任意一个 int main() { int ival; int *pval; ival = pval = 0; // error: not the same types // ... } 下列赋值有可能合法 也可能非法 但它不能用作 ival 和 jval 的定义 int main() { // ... int ival = jval = 0; // 可能合法 也可能不合法 // ... } 只有当 jval 在前面已经被定义 而且是可被赋值为 0 的某些类型时 这个例子才是合法 的 在这种情况下 ival 被初始化为向 jval 赋值 0 的结果 也是 0 为了让它定义两个对象 我们必须重写代码 int main() { // ok: 定义 ival 和 jval int ival = 0, jval = 0; // ... } 我们经常把某个操作符应用在一个对象上 然后再把结果赋给这个对象 如 int arraySum( int ia[], int sz ) { int sum = 0; for ( int i = 0; i < sz; ++i ) sum = sum + ia[ i ]; return sum; a op= b; } 126 第四章 表达式 为此 C++提供了一套复合赋值操作符 例如 前面的函数可以用 复合赋值加操作符 compound assignment-plus operator 重写为 int arraySum( int ia[], int sz ) { int sum = 0; for ( int i = 0; i < sz; ++i ) // equivalent of: sum = sum + ia[ i ]; sum += ia[ i ]; return sum; } 复合赋值操作符的一般语法格式是 a op= b; 这里的 op=可以是下列十个操作符之一 += -= *= /= %= <<= >>= &= ^= |= 每个复合赋值操作符都等价于以下 普通写法 的赋值 a = a op b; 例如 数组 ia 求和的普通写法为 sum = sum + ia [ i ]; 练习 4.6 下列代码合法吗 为什么 怎样改正 int main() { float fval; int ival; int *pi; fval = ival = pi = 0; } 练习 4.7 虽然下列表达式不是非法的 但是它们的行为并不像程序员期望的那样 为什么 怎样 修改以使其能反映程序员的可能意图 (a) if ( ptr = retrieve_pointer() != 0 ) (b) if ( ival = 1024 ) (c) ival += ival + 1; 4.5 递增和递减操作符 递增 ++ 和递减 -- 操作符为对象加 1 或减 1 操作提供了方便简短的表示 它们最 一般的用法是对索引 迭代器或指向一个集合内部的指针加 1 或减 1 例如 127 第四章 表达式 #include #include int main() { int ia[10] = {0,1,2,3,4,5,6,7,8,9}; vector< int > ivec( 10 ); int ix_vec = 0, ix_ia = 9; while ( ix_vec < 10 ) ivec[ ix_vec++ ] = ia[ ix_ia-- ]; int *pia = &ia[9]; vector::iterator iter = ivec.begin(); while ( iter != ivec.end() ) assert( *iter++ == *pia-- ); } 表达式 ix_vec++ 是递增操作符的后置 postfix 形式 它在用 ix_vec 的当前值索引 ivec 之后 将 ix_vec 递增 1 例如 while 循环的第一次迭代 ix_vec 的值为 0 这个值被用来索引 ivec 然后 ix_vec 被递增为 1 但这个新值直到下一次迭代才能被实际使用 递减操作符的后置形式用 法相同 ix_ia 的当前值被用来索引 ia 然后 ix_ia 被递减 1 C++也支持这两个操作符的前置 prefix 版本 在前置形式中 当前值先被递增或递减 1 然后再使用它的值 因此 如果写 // 错误; 两端都差一 int ix_vec = 0, ix_ia = 9; while ( ix_vec < 10 ) ivec[ ++ix_vec ] = ia[ --ix_ia ]; 则在 ix_vec 的值被用来索引 ivec 之前 它先被递增变成 1 类似地 ix_ia 在被用来索引 ia 之前先被递减变成 8 为了使循环能正确执行 我们必须将两个索引的初始值设为一个比 实际访问的值小 1 另一个比实际访问的值大 1 // ok: 两端都是正确的 int ix_vec = -1, ix_ia = 10; while ( ix_vec < 10 ) ivec[ ++ix_vec ] = ia[ --ix_ia ]; 作为最后一个例子 我们考虑栈 stack 的设计 栈是一个基本的计算机科学的数据抽 象 它允许以后进先出 LIFO 的顺序放入或取出数值 栈的两个基本操作是 向栈中压入 push 一个新的值 以及 从栈中弹出 pop 最后的值 为讨论方便 假设我们用 vector 来实现栈 我们的栈维护了一个对象 top 它表示下一个可用来压入数据值的槽 要实现压入 push 语义 我们必须把这个值赋给由 top 表示的槽 然后再将 top 增加 1 这种情况需要哪种形式 的递增操作符呢 我们希望先使用当前的值 然后再把它加 1 这正好是后置形式的行为 128 第四章 表达式 stack[ top++ ] = value; 要实现弹出 pop 语义 则必须先将 top 减 1 然后再返回减 1 后的 top 值所指的槽内 的内容 这正是前置形式的行为 int value = stack[ --top ]; 在本章最后将提供栈类的实现 标准库的栈类将在 6.16 节讨论 练习 4.8 你认为为什么 C++不叫++C 4.6 复数操作 标准库提供的复数 complex 类是基于对象的类抽象的完美模型 通过使用操作符重载 我们几乎可以像使用简单内置类型一样容易地使用复数类型的对象 正如本节我们将要看到 的 C++不但支持一般的算术操作符 如加 减 乘 除 而且还支持复数类型与内置类型 的混合运算 在程序员看来 尽管复数的实现是在标准库中 但它也是基本语言的一部分 注意本节只说明复数类的用法 有关复数的数学知识 请参见 [PERSON68] 或任意一本关于 初等数学的书籍 例如可以写 #include complex< double > a; complex< double > b; // ... assign to a and b ... complex< double > c = a * b + a / b; 在表达式中 我们可以混合复数类型和算术数据类型 例如 complex< double > complex_obj = a + 3.14159; 类似地 我们也可以用一个算术数据类型的值对复数初始化或赋值 如 double dval = 3.14159; complex_obj = dval; 或 int ival = 3; 但是 相反的情形并不被自动支持 也就是说 算术数据类型不能直接被一个复数类对 象初始化或赋值 例如 下列代码将导致编译错误 // 错误: 从复数到内置算术数据类型之间 // 并没有隐式转换支持 double dval = complex_obj; 如果我们真想这样做 则必须显式地指明我们要用复数对象的哪部分来赋值 复数类支 129 第四章 表达式 持一对操作 可用来读取一部或者虚部 例如 我们可以用成员访问语法 member access syntax double re = complex_obj.real(); double im = complex_obj.imag(); 或者用等价的非成员语法 // 等价于上面的成员语法 double re = real( complex_obj ); double im = imag( complex_obj ); 复数类支持四种复合赋值操作符 分别是加赋值 += 减赋值 -= 乘赋值 *= 以及除赋值 /= 因此 我们可以写 complex_obj += second_complex_obj; C++支持复数的输入和输出 复数的输出是一个由逗号分隔的序列对 它们由括号括起 第一个值是实部 第二个值是虚部 例如 complex< double > complex0( 3.14159, -2.171 ); complex< double > complex1( complex0.real() ); cout << complex0 << " " << complex1 << endl; 产生下列输出 ( 3.14159, -2.171 ) ( 3.14159, 0.0 ) 下列任意一种数值表示格式都可以被读作复数 // 复数的有效输入格式 // 3.14159 ==> complex( 3.14159 ); // ( 3.14159 ) ==> complex( 3.14159 ); // ( 3.14, -1.0 ) ==> complex( 3.14, -1.0 ); // 可以被读入复数对象中 // cin >> a >> b >> c // 这里 a b 和 c 为复数对象 3.14159 ( 3.14159 ) ( 3.14, -1.0 ) 复数类支持的其他操作包括 sqrt() abs() polar() sin() cos() tan() exp() log() log10() 以及 pow() 练习 4.9 当我写这本书的时候 在标准库的 Rogue Wave 实现版本中 对于复合赋值操作符 只 有当右操作数是复数的时候它才是合法的 例如 下面这样的写法 complex_obj += 1; 将导致编译错误 尽管标准 C++认为这里的赋值是合法的 跟不上 C++标准的实现是很 普遍的 我们可以通过 提供自己的复合赋值操作符实例 来修正这个错误 例如 下面 130 第四章 表达式 是一个 complex的非模板的复合加法赋值操作符的实例 #include inline complex& operator+=( complex &cval, double dval ) { return cval += complex( dval ); } 当我们在程序中包含这个实例时 上面 1 的复合赋值就能正确执行 这是为某一个类 型提供重载操作符的例子 操作符重载将在第 15 章讨论 用前面的定义作模型 我们可以为 complex提供另外三个复合操作符的实现 把它们加到下面的小程序中并运行它们 #include #include // 把复合操作符的定义放在这里 int main() { complex< double > cval( 4.0, 1.0 ); cout << cval << endl; cval += 1; cout << cval << endl; cval -= 1; cout << cval << endl; cval *= 2; cout << cval << endl; cout /= 2; cout << cval << endl; } 练习 4.10 标准 C++不提供对复数类型递增操作符的支持 尽管这不是由于复数自身的原因——毕 竟 如下语句 cal += 1; 实际上是把 cval 的实部加 1 请提供递增操作符的定义 并把它加到下列程序中 然后 编译并运行它 #include #include // 递增操作符的定义在这里 131 第四章 表达式 int main() { complex< double > cval( 4.0, 1.0 ); cout << cval << endl; ++cval; cout << cval << endl; } 4.7 条件操作符 条件操作符为简单的 if-else 语句提供了一种便利的替代表示法 例如我们不必这样写 bool is_equal = false; if ( !strcmp( str1, str2 )) is_equal = true; 而可以写成 bool is_equal = !strcmp( str1, str2 ) ? true : false; 条件操作符的语法格式如下 expr1 ? expr2 : expr3; expr1的计算结果不是 true 就是 false 如果它是 true 则 expr2 被计算 否则 expr3 被计 算 例如 int min( int ia, int ib ) { return ( ia < ib ) ? ia : ib; } } 是如下代码的简写形式 int min( int ia, int ib ) { if ( ia < ib ) return ia; return ib; } 下面的程序说明了怎样使用条件操作符 #include int main() { int i = 10, j = 20, k = 30; cout << "The larger value of " << i << " and " << j << " is " << ( i > j << i : j ) << endl; cout << "The value of " << i << " is" 132 第四章 表达式 << ( i % 2 << " odd." : " even." ) << endl; /* 条件操作符可以被嵌套 /* 但是深度的嵌套比较难读 /* 在本例中 /* max 被设置为 3 个变量中的最大值 */ int max = ( (i > j) ? (( i > k) ? i : k) : ( j > k ) ? j : k); cout << "The larger value of " << i << ", " << j << " and " << k << " is " << max << endl; } 编译并运行这个程序 产生下列输出 The larger value of 10 and 20 is 20 The value of 10 is even The larger value of 10, 20 and 30 is 30 4.6 sizeof 操作符 siseof操作符的作用是返回一个对象或类型名的字节长度 它有以下三种形式 sizeof (type name ); sizeof ( object ); sizeof object; 返回值的类型是 size_t 这是一种与机器相关的 typedef 定义 我们可以在 cstddef 头文 件中找到它的定义 下面的例子使用了 sizeof 的两种格式 #include int ia[] = { 0, 1, 2 }; // sizeof 返回整个数组的大小 size_t array_size = sizeof ia; // sizeof 返回 int 类型的大小 size_t element_size = array_size / sizeof( int ); 当 sizeof 操作符应用在数组上时 例如上面例子中的 ia 它返回整个数组的字节长度 而不是第一个元素的长度 也不是 ia 包含的元素的个数 例如 在一台 int 类型是 4 个字节 长的机器上 sizeof 指示 ia 的长度是 12 字节 类似地 当我们写如下代码时 int *pi = new int[ 3 ]; size_t pointer_size = sizeof ( pi ); 133 第四章 表达式 sizeof(pi)返回的值是指向 int 型的指针的字节长度 而不是 pi 指向的数组的长度 下面的小函数可以用来练习 sizeof()操作符 #include #include #include int main() { size_t ia; ia = sizeof( ia ); // ok ia = sizeof ia; // ok // ia = sizeof int; // 错误 ia = sizeof( int ); // ok int *pi = new int[ 12 ]; cout << "pi: " << sizeof( pi ) << " *pi: " << sizeof( *pi ) << endl; // 一个 string 的大小与它所指的字符串的长度无关 string st1( "foobar" ); string st2( "a mighty oak" ); string *ps = &st1; cout << "st1: " << sizeof( st1 ) << " st2: " << sizeof( st2 ) << " ps: " << sizeof( ps ) << " *ps: " << sizeof( *ps ) << endl; cout << "short :\t" << sizeof(short) << endl; cout << "short* :\t" << sizeof(short*) << endl; cout << "short& :\t" << sizeof(short&) << endl; cout << "short[3] :\t" << sizeof(short[3]) << endl; } 编译并运行它 产生如下结果 pi: 4 *pi: 4 st1: 12 st2: 12 ps: 4 *ps: 12 short : 2 short* : 4 short& : 2 134 第四章 表达式 short[3] : 6 正如上面的例子程序所显示的那样 应用在指针类型上的 sizeof 操作符返回的是包含该 类型地址所需的内存长度 但是 应用在引用类型上的 sizeof 操作符返回的是包含被引用对 象所需的内存长度 sizeof操作符应用在 char 类型上时 在所有的 C++实现中结果都是 1 // 在所有的实现中 保证为 1 size_t char_size = sizeof( char ); sizeof操作符在编译时刻计算 因此被看作是常量表达式 它可以用在任何需要常量表 达式的地方 如数组的维数或模板的非类型参数 例如 // ok: 编译时刻常量表达式 int array[ sizeof( some_type_T )]; 4.9 new 和 delete 表达式 系统为每个程序都提供了一个在程序执行时可用的内存池 这个可用内存池被称为程序 的空闲存储区 free store 或堆 heap 运行时刻的内存分配被称为动态内存分配 dynamic memory allocation 正如我们在第 1 章中所看到的 动态内存分配由 new 表达式应用在一 个类型指示符 specifier 上来完成 类型指示符可以是内置类型或用户定义类型 new 表达 式返回指向新分配的对象的指针 例如 int *pi = new int; 从空闲存储区中分配了一个 int 型的对象 并用它的地址初始化 pi 在空闲存储区内实 际分配的对象并没有被初始化 我们可以如下指定一个初始值 int *pi = new int( 1024 ); 它不但分配了这个对象而且用 1024 将其初始化 要动态分配一个对象数组 我们可以写成 int *pia = new int[ 10 ]; 它从空闲存储区中分配了一个数组 其中含有 10 个 int 型对象 并用它的地址初始化 pin 而数组的元素没有被初始化 没有语法能为动态分配的数组的元素指定一个显式的初始值集 合 在类对象数组的情况下 如果我们定义了缺省构造函数 那么它将被顺次应用在数组 的每一个元素上 例如 string *ps = new string; 从空闲存储区分配了一个 string 类对象 并用它的地址初始化 ps 然后再在该对象上调 用 string 类缺省构造函数 类似地 如下语句 string *psa = new string[ 10 ]; 从空闲存储区分配了一个含有 10 个 string 类对象的数组 用它的地址初始化 psa 然后 135 第四章 表达式 依次在每个元素上调用 string 类的缺省构造函数 所有从空闲存储区分配的对象都是未命名的 这是它的另一个特点 new 表达式并不返 回实际被分配的对象 而且返回这个对象的地址 对象的所有操作都通过这个地址间接来完 成 当对象完成了使命时 我们必须显式地把对象的内存返还给空闲存储区 我们通过把 delete 表达式应用在 指向我们用 new 表达式分配的对象指针 上来做到这一点 delete 表达 式不应该被应用在 不是通过 new 表达式分配的指针 上 例如 delete pi; 释放了 pi 指向的 int 对象 将其返还给空闲存储区 类似地 delete ps; 在 ps 指向的 string 类对象上应用 string 的析构函数后 释放其存储区 并将其返还给空 闲存储区 最后 delete [] pia; 释放了 pia 指向的 10 个 int 对象的数组 并把相关的内存区返还给空闲存储区 在关键 字 delete 与指针之间的空方括号表示 delete 的一种特殊语法 它释放由 new 表达式分配的数 组的存储区 第 8 章将详细介绍动态内存分配以及 new 表达式与 delete 表达式的用法 练习 4.11 下列语句哪些是非法的或错误的 (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; 4.10 逗号操作符 逗号表达式是一系列由逗号分开的表达式 这些表达式从左向右计算 逗号表达式的结 果是最右边表达式的值 在下面的例子中 条件操作符的每边都是逗号表达式 第一个逗号 表达式的值是 ix 而第二个表达式的值是 0 int main() { 136 第四章 表达式 // examples of a comma expression // ia, sz, and index are defined elsewhere ... int ival = (ia != 0) ? ix=get_value(), ia[index]=ix : ia=new int[sz], ia[index ]=0; // ... } 4.11 位操作符 表格 4.3 位操作符 操作符 功能 用法 ~ 按位非 ~expr << 左移 expr1 << expr2 >> 右移 expr1 >> expr2 & 按位与 expr1 & expr2 ^ 按位异或 expr1 ^ expr2 | 按位或 expr1 | expr2 &= 按位与赋值 expr1 &= expr2 ^= 按位异或赋值 expr1 ^= expr2 |= 按位或赋值 expr1 |= expr2 位操作符把操作数解释成有序的位集合 这些位可能是独立的 也可能组成域 field 每个位可以含有 0 off 或 1 on 位操作符允许程序员设置或测试独立的位或位域 如 果一个对象被用作一组位或位域的离散集合 那么这样的对象称为位向量 bitvector 位 向量是一种用来记录一组项目或条件的是/否信息 有时也称为标志 flag 的紧缩方法 例如 在编译器中 类型声明的限定修饰符 qualifier 如 const 和 volatile 有时就被存储 在位向量中 iostream 库用位向量表示格式状态 例如输出的整数是以十进制 十六进制 还是八进制显示 正如标准 C++有两种方式支持字符串 string 类类型和 C 风格字符串 以及元素的有序 集合 模板 vector 类和内置数组类型 一样 它也有两种方式支持位向量 在 C 语言和标准 C++之前 它用内置整值类型来表示位向量 典型的情况是用 unsigned int 对象提供位的容 器 程序员用本节讨论的位操作符来管理语义 标准库提供了一个 bitset 类 它支持位向量 的类抽象 bitset 对象封装了位向量的语义 它回答了诸如以下问题的询问 有设置为 1 的位 吗 设置了多少位 它提供了一组用于管理位的设置 复位和测试的操作 137 第四章 表达式 通常情况下 我们建议使用标准库的类抽象——在这种情况下 我们使用 bitset 类 而 不是直接按位操作整值数据类型 但是 我们认为 知道这两种表示法仍然是有必要的 因 为我们可能要阅读或修改已有的一些代码 考虑到本书的完整性 我们将对这两种方法做必 要的解释 在本节余下部分 我们将了解内置整值类型作为位向量的用法以及位操作符的用 法 下一节将介绍 bitset 类 用整值数据类型作为位向量时 类型可以是有符号的 也可以是无符号的 强烈建议使 用无符号类型 因为在大多数的位操作中 符号位的处理是未定义的 因此在不同的实现中 符号位的处理可能会不同 在一个实现下可行的程序 在另一个实现下有可能会失败 按位非操作符 ~ 翻转操作数的每一位 每个 1 被设置为 0 而每个 0 被设置为 1 移位操作符 << >> 将其左边操作数的位向左或右移动某些位 操作数中移到外面的 位被丢弃 左移操作符 << 从右边开始用 0 补空位 如果操作数是无符号数 则右移操作 符 >> 从左边开始插入 0 否则的话 它或者插入符号位的拷贝 或者插入 0 这由具体 实现定义 按位与操作符 & 需要两个整值操作数 在每个位所在处 如果两个操作数都含有 1 则结果该位为 1 否则为 0 请不要把该操作符与逻辑与 && 操作符相混淆 不幸的是 好像每个人都会混淆一两次 按位异或操作符 ^ 需要两个整值操作数 在每个位所在处 如果两个操作数只有一个 注意不是两个 含有 1 则结果该位为 1 否则为 0 按位或操作符 | 需要两个整值操作数 在每个位所在处 如果两个操作数有一个或者 两个含有 1 则结果该位为 1 否则为 0 请不要把该操作符与逻辑或 || 操作符混淆 让我们来看一个简单的例子 一个老师教一个有 30 名学生的班级 每周这个班都有一个 通过/不通过的测试 我们用一个位向量来记录每次测试的结果 注意 每个位的位置都是 从 0 开始计数的 因此位置 1 实际上代表了第二位 在本例中 我们为了把位置 1 做成第一 位 位置 2 做成第二位等等 浪费了第一位 毕竟我们的老师不是计算机科学的学生 unsigned int quiz 1 = 0; 这个老师必须能够翻转和测试独立的位 例如 27 号学生补考并通过了 那么 老师必 须将第 27 位设置为 1 第一步是将一个整数的第 27 位设为 1 而其他位保持为 0 这可以用 整形常数和左移操作符来实现 1 << 27; 如果这个值与 quiz1 按位或 则除了第 27 位 其他位都没有改变 第 27 位被设置为 1 quiz1 |= 1 << 27; 假设这个老师重新检查了测验结果 发现 27 号学生实际上并没有通过补考 那么现在这 个老师必须将第 27 位再改为 0 注意 这次是将前面的整数翻转 将按位非操作符应用在前 面的整数上 会把除了第 27 位外的每一位都设置为 1 ~( 1<<27 ); 如果该值与 quiz1 按位与 则除了第 27 位外 其他位都保持不变 而第 27 位被设置 为 0 138 第四章 表达式 quiz1 &= ~(1<<27); 下面给出这个老师怎样判断某位是 0 还是 1 还是考虑 27 号学生 实际上 她的名字叫 Anna 第一步是将一个整数的第 27 位设置为 1 然后再把该值与 quiz1 按位与 如果 quiz1 的第 27 位也是 1 则结果为 true 否则结果为 false bool hasPassed = quiz1 & (1<< 27); 由于位操作符在较低的层次上操纵位 所以它比较容易出错 因此在典型情况下 它们 被封装在预处理器宏或内联函数中 例如 inline bool bit_on( unsigned int ui, int pos ) { return ui & (1 << pos ); } 可以如下调用它们 enum students { Danny = 1, Jeffrey, Ethan, Zev, Ebie, // ... AnnaP = 26, AnnaL = 27 }; const int student_size = 27; //deliberately starts at 1 bool has_passed_quiz[ student_size+1 ]; for ( int index = 1; index <= student_size; ++index ) has_passed_quiz[ index ] = bit_on( quiz1, index ); 当然 一旦我们封装了位操作符的直接用法 下一个逻辑步骤就是要提供整个位向量的 封装——在标准库的情况下 也就是 bitset 类抽象 这正是下一节的主题 练习 4.12 假设有下面两个定义 unsigned int ui1 = 3, ui2 = 7; 下列表达式的结果是什么 (a) ui1 & ui2 (c) ui1 | ui2 (b) ui1 && ui2 (d)ui1 || ui2 练习 4.13 请以内联函数 bit_on()为模型 提供一组内联函数 它们的操作作用在由 unsigned int 表 示的位向量上 这组函数包括 bit_turn_on() 将指定位设置为 1 bitoff() 测试指定的位 是否为 0 bit_turn_off() 将指定位设置为 0 和 flip_bit() 将指定位翻转 然后写一个 小程序练习这些函数 练习 4.14 在练习 4.13 中 显式地编写函数来操作 unsigned int 对象的缺点是什么 一种替代方法 是使用 typedef 另一种替代方法是使用 2.5 节介绍的模板机制 分别用 typedef 和模板机制 139 第四章 表达式 重写上面的内联函数 bit_on() 4.12 bitset 操作 表格 4.4 bitset 操作 操作 功能 用法 test( pos ) pos 位是否为 1 a.test( 4 ) any() 任意位是否为 1 a.any() none() 是否没有位为 1 a.none() count() 值是 1 的位的个数 a.count() size() 位元素的个数 a.size() [pos] 访问 pos 位 a[ 4 ] flip() 翻转所有的位 a.flip() flip( pos ) 翻转 pos 位 a.flip( 4 ) set() 将所有位置 1 a.set() set( pos ) 将 pos 位置 1 a.set( 4 ) reset() 将所有位置 0 a.reset() reset(pos) 将 pos 位置 0 a.reset( 4 ) 用整值类型表示位向量的问题在于 使用位操作符来设置 复位和测试单独的位 层次 比较低 也比较复杂 例如 用整值类型将第 27 位设置为 1 我们这样写 quiz1 |= 1<<27; 而用 bitset 来做 我们可以写 quizl[ 27 ] = 1; 或 quiz1.set( 27 ); 正如上节所提到的 位的计数从 0 开始 实际上 27 位指第 28 位 在本例中 我们 浪费了第一位 所以我们的位从 1 开始 要使用 bitset 类 我们必须包含相关的头文件 #include bitset有三种声明方式 在缺省定义中 我们只需简单地指明位向量的长度 例如 bitset< 32 > bitvec; 140 第四章 表达式 声明了一个含有 32 个位的 bitset 对象 位的顺序从 0 到 31 缺省情况下 所有的位都被 初始化为 0 为了测试 bitset 对象是否含有被设置为 1 的位 我们可以使用 any()操作 当 bitset 对象的一位或多个位被设置为 1 时 any()返回 true 对于 bitvec 如下测试 bool is_set = bitvec.any(); 它的结果当然是 false 相反 如果 bitset 对象的所有位都被设置为 0 则 none()操作返回 true 对于 bitvec 测试 bool is_not_set = bitvec.none(); 结果为 true count()操作返回被设置为 1 的位的个数 int bits_set = bitvec.count(); 我们可以用 set()操作或者下标操作符来设置某个单独的位 例如 下面的 for 循环把偶 数位设置为 1 for ( int index = 0; index < 32; ++ index ) if ( index % 2 == 0 ) bitvec[ index ] = 1; 类似地 测试某个单独的位是否为 1 也有两种方式 test()操作用位置做参数 返回 true 或 false 例如 if ( bitvec.test( 0 )) // 我们的 bitve[0] 可以工作了! 同样地 我们也可以用下标操作符 cout << "bitvec: positions turned on:\n\t"; for ( int index = 0; index < 32; ++index ) if ( bitvec[ index ] ) cout << index << " "; cout << endl; 要将某个单独的位设置为 0 我们可以用 reset()或下标操作符 下列两个操作都将 bitvec 的第一位设为 0 // 两者等价 都把第一位设置为 0 bitvec.reset( 0 ); bitvec[ 0 ] = 0; 我们也可以用 set()和 reset()操作将整个 bitset 对象的所有位设为 1 或 0 只要调用相应的 操作 而不必传递位置参数 我们就可以做到这一点 例如 // 把所有的位设置为 0 bitvec.reset(); if ( bitvec.none() != true ) // 喔! 错了 // 把所有的位设置为 1 bitvec.set(); 141 第四章 表达式 if ( bitvec.any() != true ) // 喔! 又错了 flip()操作翻转整个 bitset 对象或一个独立的位 bitvec.flip( 0 ); // 翻转第一位 bitvec[0].flip(); // 也是翻转第一位 bitvec.flip(); // 翻转所有的位的值 还有两种方法可以构造 bitset 对象 它们都提供了将某位初始化为 1 的方式 一种方法 是 为构造函数显式地提供一个无符号参数 bitset 对象的前 N 位被初始化为参数的相应位 值 例如 bitset< 32 > bitvec2( 0xffff ); 将 bitvec2 的低 16 位设为 1 00000000000000001111111111111111 下面的 bitvec3 的定义 bitset< 32 > bitvec3( 012 ); 将第 1 和 3 位的值设置为 1 假设位置从 0 开始计数 00000000000000000000000000001010 我们还可以传递一个代表 0 和 1 的集合的字符串参数来构造 bitset 对象 如下所示 // 与 bitvec3 的初始化等价 string bitval( "1010" ); bitset< 32 > bitvec4( bitval ); bitvec4和 bitvec3 的第 1 和 3 位都被设置为 1 而其他位保持为 0 我们还可以标记用来初始化 bitset 的字符串的范围 例如 在下面的语句中 // 从位置 6 开始, 长度为 4: 1010 string bitval( "1111110101100011010101" ); bitset< 32 > bitvec5( bitval, 6, 4 ); bitvec5的第 1 和第 3 位被初始化为 1 其他位为 0 同 bitvec3 和 bitvec4 一样 如果去 掉用来指示字符串范围长度的第 3 个参数 那么 初始化字符的范围由指定的位置开始一直 到字符串的末尾 例如 // 从位置 6 开始 直到字符串结束: 1010101 string bitval( "1111110101100011010101" ); bitset< 32 > bitvec6( bitval, 6 ); bitset类支持两个成员函数 它们能将 bitset 对象转换成其他类型 一种情况是用 to_string() 操作 将任意 bitset 对象转换成 string 表示 string bitval( bitvec3.to_string() ); 另一种情况是用 to_ulong()操作 将任意 bitset 对象转换成 unsigned long 型的整数表示 只要该 bitset 对象的底层表示能用一个 unsigned long 来表示 在需要把 bitset 对象传递给 C 142 第四章 表达式 或标准 C++之前的程序时 这尤其有用 bitset类支持位操作符 例如 bitset<32> bitvec7 = bitvec2 & bitvec3; 把 bitvec7 初始化为两个位向量按位与的结果 而 bitset<32> bitvec8 = bitvec2 | bitvec3; 把 bitvec8 初始化为按位或的结果 它也支持按位复合赋值操作符和按位移位操作符 练习 4.15 下列 bitset 对象的声明哪些是错误的 (a) bitset<64> bitvec(32); (b) bitset<32> bv( 1010101 ); (c) string bstr; cin >> bstr; bitset<8>bv( bstr ); (d) bitset<32> bv; bitset<16> bv16( bv ); 练习 4.16 下列 bitset 对象的用法哪些是错误的 extern void bitstring(const char*); bool bit_on(unsigned long, int ); bitset<32> bitvec; (a) bitstring( bitvec.to_string().c_str() ); (b) if ( bit_on( bitvec.to_long(), 64 )) ... (c) bitvec.flip( bitvec.count() ); 练习 4.17 已知考虑序列 1 2 3 5 8 13 21 怎样初始化一个 bitset<32>来表示这个序列 已 知一个空 bitset 写一个小程序来设置每一个合适的位 4.13 优先级 操作符优先级是指复合表达式中操作符计算的顺序 例如 在下面的定义中 最终被赋 给 ival 的是什么 int ival = 6 + 3 * 4 / 2 + 2; 纯粹从左到右的计算结果为 20 其他可能的结果包括 9 14 和 36 哪一个是实际被赋给 ival 的值呢 14 在 C++中 乘法和除法的优先级比加法高 这意味着它们先被计算 但乘法和除法的优 先级相同 所以它们将按从左至右的顺序被计算 同此 表达式的计算顺序如下 1 3 * 4 => 12 143 第四章 表达式 2 12 / 2 => 6 3 6 + 6 => 12 4 12 + 2 => 14 下面的 while 循环条件测试的行为与程序员的意图完全不同 因为与不等于操作符相比 赋位操作符的优先级比较低 while ( ch = nextChar() != '\n' ) 编程者的意图是将下一个字符赋给的 ch 然后测试该字符是否为 \n 然而表达式的行 为却是测试下一个字符是否为 \n 然后再把测试的结果 true 或 false 赋给 ch 而不会把下 一个字符赋给 ch 用括号把一些子表达式括起来 可以改变优先级 在复合表达式计算中 第一个动作是 计算所有括号中的子表达式 再用计算的结果代替每个子表达式 然后继续计算 里边的括 号比外面的括号先计算 例如 4 * 5 + 7 * 2 ==> 34 4 * ( 5 + 7 * 2 ) ==> 76 4 * ( (5 + 7) * 2 ) ==> 96 下面的 while 循环用括号正确地把赋值子表达式括起来 正好反映了编程者的意图 while ( (ch = nextChar()) != '\n' ) 操作符具有优先级和结合性 例如 赋值操作符是右结合的 被连接起来的赋值表达式 ival = jval = kval = lval // 右结合的 先把 lval 赋值给 kval 然后再把结果赋值给 jval 最后把结果赋值给 ival 另一方面 算术操作符是左结合的 表达式 ival + jval + kval + lval // 左结合的 先把 ival 和 jval 相加 然后冉加上 kval 最后加上 lval 表 4.5 按照优先级顺序给出了 C++操作符的全集 表中每一段的操作符的优先级都相同 每一段中的操作符的优先级高于下一段中的操作符 例如 乘 除操作符的优先级相同 它 们都比关系操作符的优先级高 练习 4.18 参照表 4.5 指出下列复合表达式的计算顺序 (a) ! ptr == ptr->next (b) ~ uc ^ 0377 & ui ? 4 (c) ch = buf[ bp++ ] != '\n' 练习 4.19 练习 4.18 中的三个表达式的计算顺序与程序员的意图相反 把它加上括号使其符合你想 像中的程序员意图 144 第四章 表达式 练习 4.20 由于操作符优先级的问题 下面两个表达式编译失败 请参照表 4.5 解释原因 应该怎 样改正呢 (a) int i = doSomething(), 0; (b) cout ? ival % 2 ? "odd" : "even"; 表格 4.5 操作符优先级 操作符 功能 用法 :: 全局域 ::name :: 类域 ::name :: 名字空间域 namespace::name . 成员选择 object.member -> 成员选择 pointer->member [] 下标 variable[ expr ] () 函数调用 name(expr_list) () 类型构造 type(expr_list) ++ 后置递增 lvalue++ -- 后置递减 lvalue-- typeid 类型 ID typeid(type) typeid 运行时刻类型 ID typeid(expr) const_cast 类型转换 const_cast(expr) dynamic_cast 类型转换 dynamic_cast(expr) reinterpret_cast 类型转换 reinterpret_cast(expr) static_cast 类型转换 static_cast(expr) sizeof 对象的大小 sizeof object sizeof 类型的大小 sizeof( type ) ++ 前置递增 ++lvalue -- 前置递减 --lvalue ~ 按位非 ~expr ! 逻辑非 !expr - 一元减 -expr + 一元加 +expr 145 第四章 表达式 续表 操作符 功能 用法 * 解引用 &expr & 取地址 &expr () 类型转换 (type)expr new 分配对象 new type new 分配/初始化对象 new type(expr_list) new 分配/替换对象 new(expr_list)type(expr_list) new 分配数组 所有的形式 delete 释放对象 所有的形式 delete 释放数组 所有的形式 ->* 指向成员选择 pointer->*pointer_to_member .* 指向成员选择 object.*pointer_to_member * 乘 expr * expr / 除 expr / expr % 取模 求余 expr % expr + 加 expr + expr - 减 expr - expr << 按位左移 expr << expr >> 按位右移 expr >> expr < 小于 expr < expr <= 小于等于 expr <= expr > 大于 expr > expr >= 大于等于 expr >= expr = 等于 expr == expr != 不等于 expr != expr & 按位与 expr & expr ^ 按位异或 expr ^ expr | 按位或 expr | expr && 逻辑与 expr && expr || 逻辑或 expr || expr 146 第四章 表达式 续表 操作符 功能 用法 ?: 条件表达式 expr ? expr : expr = 赋值 lvalue = expr =,*=,/=,%=,+=,-=,<<=,>>=,&=,|=,^ = 复合赋值 lvalue += expr 等 throw 抛出异常 throw expr , 逗号 expr, expr 4.14 类型转换 考虑下列赋值 int ival = 0; // 编译器往往会给出警告 ival = 3.541 + 3; 最终结果是 ival 的值为 6 完成赋值的实际步骤如下面所述 我们首先要把两个不同 类型的值相加 这里 3.541 是 double 型的文字常量 3 是 int 型的文字常量 C++并不是把 两个不同类型的值加在一起 而是提供了一组算术转换 arithmetic conversions 以便在 执行算术运算前 将两个操作数转换成共同的类型 转换规则是 小类型总是被提升成大 类型 以防止精度损失 本例中 在执行加法前 整数 3 被提升为 double 型 这些转换由 编译器自动完成 无需程序员介入 因此 它们也被称为隐式类型转换 implicit type conversion 加法以及结果都是 double 型的 结果值为 6.541 下一步是把结果赋给 ival 如果赋位 操作符的左右两边的类型不同 那么 有可能的话 右边操作数会被转换成左边的类型 在 本例中 ival 的类型是 int double 向 int 的转换自动按截取而不是舍入进行 小数部分被直 接地抛弃 6.541 变成了 6 这就是赋给 ival 的值 因为从 double 到 int 的转换会引起精度损 失 因此大多数编译器会给出一个警告 因为从 double 到 int 的类型转换不支持舍入 所以我们需要自己写程序来实现 例 如 double dval = 8.6; int ival = 5; ival += dval + 0.5; // 保证舍入 如果原意的话 我们可以通过指定显式类型转换 explicit type conversion 来禁止标准 算术转换 // 指示编译器把 double 转换成 int 147 第四章 表达式 ival = static_cast< int >( 3.541 ) + 3; 在本例中 我们显式地指示编译器将 double 型的值转换成 int 型 而不是遵循标准 C++ 算术转换 在这一节中 我们将详细讨论隐式类型转换 如上面第一个例子 由编译器自动完成 无需编程者介入 以及显式类型转换 如上面第二个例子中 程序员通过应用强制类型转换 指示编译器把一个现有的类型转换成指定的第二种类型 4.14.1 隐式类型转换 C++定义了一组内置类型对象之间的标准转换 在必要时它们被编译器隐式地应用到对 象上 隐式类型转换发生在下列这些典型的情况下 在混合类型的算术表达式中 在这种情况下 最宽的数据类型成为目标转换类型 这也被称为算术转换 arithmetic conversion 例如 int ival = 3; double dval = 3.14159; // ival 被提升为 double 类型: 3.0 ival + dval; 用一种类型的表达式赋值给另一种类型的对象 在这种情况下 目标转换类型是被 赋值对象的类型 例如 在下面第一个赋值中 文字常量 0 的类型是 int 它被转 换成 int*型的指针 表示空地址 在第二个赋值中 double 型的值被截取成 int 型 的值 // 0 被转换成 int*类型的空指针值 int *pi = 0; // dval 被截取为 int 值 3 ival = dval; 把一个表达式传递给一个函数调用 表达式的类型与形式参数的类型不相同 在这 种情况下 目标转换类型是形式参数的类型 例如 extern double sqrt( double ); // 2 被提升为 double 类型: 2.0 cout << "The square root of 2 is " << sqrt( 2 ) << endl; 从一个函数返回一个表达式 表达式的类型与返回类型不相同 在这种情况下 目 标转换类型是函数的返回类型 例如 double difference( int ival1, int ival2 ) { // 返回值被提升为 double 类型 return ival1 - ival2; } 148 第四章 表达式 4.14.2 算术转换 算术转换保证了二元操作符 如加法或乘法 的两个操作数被提升为共同的类型 然后 再用它表示结果的类型 两个通用的指导原则如下 1 为防止精度损失 如果必要的话 类型总是被提升为较宽的类型 2 所有含有小于整型的有序类型的算术表达式 在计算之前 其类型都会被转换成整 型 规则的定义如下面所述 这些规则定义了一个类型转换层次结构 我们从最宽的类型 long double 开始 如果一个操作数的类型是 long double 那么另一个操作数无论是什么类型 都将被转换 成 long double 例如 在下面的表达式中 字符常量小写字母 a 将被提升为 long double 它的 ASC 码值为 97 然后再被加到 long double 型的文字常量上 3.14159L + 'a'; 如果两个操作数都不是 long double 型 那么当其中一个操作数的类型是 double 型 则 另一个就将被转换成 double 型 例如 int ival; float fval; double dval; // 在计算加法前 fval 和 ival 都被转换成 double dval + fval + ival; 类似地 如果两个操作数都不是 double 型 而其中一个操作数是 float 型 则另一个被 转换成 float 型 例如 char cval; int ival; float fval; // 在计算加法前 ival 和 cval 都被转换成 double cval + fval + ival; 否则 因为两个操作数都不是三种浮点类型之一 它们一定是某种整值类型 在确定共 同的目标提升类型之前 编译器将在所有小于 int 的整值类型上施加一个被称为整值提升 integral promotion 的过程 在进行整值提升时 类型 char signed char unsigned char 和 short int 都被提升为类型 int 如果机器上的 m 型足够表示所有 unsinned shoft 型的值 这通常发生在 short 用半个了表 i 而 int 用一个字表示的情况下 则 unsigned short int 也被转换成 int 否则 它会被提升为 unsigned int wchar_t 和枚举类型被提升为能够表示其底层类型 underlying type 所有值的最小整数 类型 例如 已知如下枚举类型 enum status { bad, ok }; 149 第四章 表达式 相关联的值是 0 和 1 这两个值可以 但不是必须 存放在 char 类型的表示中 当这些 值实际上被作为 char 类型来存储时 char 代表了枚举的底层类型 然后 status 的整值提升将 它的底层类型转换为 int 在下列表达式中 char cval; bool found; enum mumble { m1, m2, m3 } mval; unsigned long ulong; cval + ulong; ulong + found; mval + ulong; 在确定两个操作数被提升的公共类型之前 cval found 和 mval 都被提升为 int 类 型 一旦整值提升执行完毕 类型比较就又一次开始 如果一个操作数是 unsigned long 型 则第二个也被转换成 unsigned long 型 在上面的例子中 所有被加到 ulong 上的三个对象都 被提升为 unsigned long 型 如果两个操作数的类型都不是 unsigned long 而其中一个操作数是 long 型 则另一个也 被转换成 long 型 例如 char cval; long lval; // 在计算加法前 cval 和 1024 都被提升为 long 型 cval + 1024 + lval; long类型的一般转换有一个例外 如果一个操作数是 long 型 而另一个是 unsigned int 型 那么 只有机器上的 long 型足够长以便能够存放 unsigned int 的所有值时 一般来说 在 32 位操作系统中 long 型和 int 型都用一个字长来表示 所以不满足这里的假设条件 unsigned int 才会被转换为 long 型 否则两个操作数都被提升为 unsigned long 型 若两个操作数都不是long型 而其中一个是unsigned int型 则另一个也被转换成unsigned int 型 否则 两个操作数一定都是 int 型 尽管算术转换的这些规则可能给你的困惑多于启发 但是 一般的思想是 尽可能地保 留多类型表达式中涉及到的值的精度 这正是通过 把不同的类型提升到当前出现的最宽的 类型 来实现的 4.14.3 显式转换 显式转换也被称为强制类型转换 cast 包括下列命名的强制类型转换操作符 static_cast dynamic_cast const_cast 和 reinterpret_cast 虽然有时候确实需要强制类型转 换 但是它们也是程序错误的源泉 通过使用它们 程序员关闭了 C++语言的类型检查设 施 在了解怎样把一个值从一种类型强制转换成另一种类型之前 我们先来看一下何时需 要这么做 任何非 const 数据类型的指针都可以被赋值给 void*型的指针 void*型指针被用于 对象 150 第四章 表达式 的确切类型未知 或者 在特定环境下对象的类型会发生变化 的情况下 有时 void*型的 指针被称为泛型 generic 指针 因为它可以指向任意数据类型的指针 例如 int ival; int *pi = 0; char *pc = 0; void *pv; pv = pi; // ok: 隐式转换 pv = pc; // ok: 隐式转换 const int *pci = &ival; pv = pci; // 错误: pv 不是一个 const void*. const void *pcv = pci; // ok 但是 void*型指针不能直接被解除引用 因为没有类型信息可用来指导编译器怎样解释 底层的位模式 相反 void*的指针必须先被转换成某种特定类型的指针 但是 在 C++中 不存在从 void*型指针到特殊类型的指针之间的自动转换 例如 考虑下列代码 #include int ival = 1024; void *pv; int *pi = &ival; const char *pc = "a casting call"; void mumble() { pv = pi; // ok: pv 指向 ival pc = pv; // 错误: 没有标准的转换 char *pstr = new char[ strlen( pc )+1 ]; strcpy( pstr, pc ); } 在这种情况下 程序员在把 pv 赋给 pc 时显然犯了错误 因为 pv 指向一个整数而不是一 个字符数组 随后 当 pc 被传递给函数 strlen()时 由于函数需要一个以空字符结尾的字符 串 因而导致程序出现了严重错误 而在执行 strcpy()时 对于这个程序 我们希望最好的结 果也就是程序异常终止了 很容易看出是什么使这个错误难以修正 这就是为什么在 把 void* 型的指针赋值给任意显式类型 时 C++要求显式强制转换的原因 void mumble() { // ok: 仍然是错误的, 但是现在可以通过编译! // 因为在赋值前用了显式强制转换 // 当程序失败时 应该首先检查强制转换 pc = static_cast< char* >( pv ); 151 第四章 表达式 // 仍然是一个灾难 char *pstr = new char[ strlen( pc )+1 ]; strcpy( pstr, pc ); } 执行显式强制转换的第二个原因是希望改变通常的标准转换 例如 下列复合赋值 首先将 ival 提升成 double 型 然后再把它加到 dval 上 最后把结果截取成 int 型来执行赋 值 double dval; int ival; ival += dval; 我们通过显式地将 dval 强制转换成 int 型 消除了把 ival 从 int 型到 double 型的不必要 提升 ival += static_cast< int >( dval ); 进行显式强制转换的第三个原因是要避免出现多种转换可能的歧义情况 我们将在第 9 章对重载函数名的讨论中更仔细地了解这种情况 显式转换符号的一股形式如下 cast-name< type >( expression ); 这里的 cast-name 是 static_cast const_cast dynamic_cast 和 reinterpret_cast 之一 const_cast 正如其名字所暗示的 将转换掉表达式的常量性 以及 volatile 对象的 volatile 性 例如 extern char *string_copy( char* ); const char *pc_str; char *pc = string_copy( const_cast< char* >( pc_str )); 试图用其他三种形式来转换掉常量性会引起编译错误 类似地 用 const_cast 来执行 般的类型转换 也会引起编译错误 编译器隐式执行的任何类型转换都可以由 static_cast 显式完成 double d = 97.0; char ch = static_cast< char >( d ); 为什么要这样做呢 因为从一个较大类型到一个较小类型的赋值 会导致编译器产生一 个警告以提醒我们潜在的精度损失 当我们提供显式强制转换时 警告消息被关闭 强制转 换告诉编译器和程序的读者 我们不关心潜在的精度损失 行为不佳的静态转换 即那些有潜在危险的类型转换 包括将 void*型的指针强制转换 成某种显式指针类型 把一个算术值强制转换成枚举型 或把一个基类强制转换成其派生类 或者这种类的指针或引用 基类与派生类的转换将在 19 章讨论 这些转换有潜在的危险是因为它们的正确性取决于在转换发生时该对象碰巧包含的值 例如 已知下列声明 152 第四章 表达式 enum mumble { first = 1, second, third }; extern int ival; mumble mums_the_word = static_cast< mumble >( ival ); 只有当 ival 含有的值是 1 2 或 3 的时候 它到 mumble 型的转换才是正确的 reinterpre_cast 通常对于操作数的位模式执行一个比较低层次的重新解释 它的正确性 很大程度上依赖于程序员的主动管理 例如 下列转换 complex *pcom; char *pc = reinterpret_cast< char* >( pcom ); 程序员必须永远也不会失去对 pc 实际指向对象的监视 例如 把它传递给一个 string 对 象 如 string str( pc ); 可能会引起 str 运行时的古怪行为 这个例子很好地说明了 显式强制转换是多么危险 由于显式的 reinterpret_cast 用复数对象的地址初始化 pc 没有引起任何来自编译器的错误或警告信息 后面对 pc 的 使用都把它当作 char*型对象 因此用 pc 初始化 str 是完全正确的 然而 当我们写如下 语句时 string str( pc ); 程序的运行时行为就是未定义的 寻找此类问题的原因非常困难 特别是 当 pcom 向 pc 的强制转换发生在与调用 strsize() 不同的文件中的时候 调用 str.size()时 期望的空字符结尾没有出现 在某种程度上 这说明了语言的矛盾性 强类型检查正是为了防止此类错误 然而 显 式强制转换又允许我们暂时挂起强类型检查 当我们用 pc 初始化 str 时 类型检查机制又重 新开始工作 并且 pc 确实是正确的类型 char* 但是 它其实并不正确 由于显式强制转 换 编译器并不知道这些 显然 禁止显式转换本身实际上是不可行的 而标准 C++引入的这些强制转换操作符又 突出了这个矛盾 在引入这些强制转换操作符之前 显式强制转换由一对括号来完成 标准 C++仍然支持这种旧式的强制转换 char *pc = (char*) pcom; 效果与使用 reinterpret_cast 符号相同 但强制转换的可视性非常差 这使跟踪错误的转 换更加困难 C++提供了各种显式强制转换符号 而不是惟一一种符号 例如 // 不是 C++ char *pc = explicit_cast< char* >( pcom ); 结果是 程序员 以及读者和操作程序的工具 能够很清楚地知道代码中每个显式强制 转换的潜在危险等级 无论何时 当我们面对令人费解的运行时刻程序行为时 可能的罪魁祸首首先会是具 有功能障碍的指针 一种原因就是无效的显式强制转换 因此 用 reinterpret_cast 操作符 153 第四章 表达式 来执行并标识出所有的显式指针强制转换是很有用的 指针错误的第二个原因是它指向 的内存变成了无效的 这可能会发生在 我们偶尔删除了一个仍然在被使用的指针 或 者 返回一个局部对象的地址 时 我们将在 8.4 节讨论动态内存分配时详细解释这个问 题 dynamic_cast 支持在运行时刻识别由指针或引用指向的类对象 对 dynamic_cast 的讨论 将在 19.1 节介绍运行时刻类型识别时再进行 4.14.4 旧式强制类型转换 前面给出的强制转换符号语法 有时被称为新式强制转换符号 它是由标准 C++引入的 在它之前 显式强制转换由非常通用的强制转换语法 现在被称为旧式强制转换符号 来实 现 虽然标准 C++仍然支持旧式强制转换符号 但是我们建议 只有当我们为 C 语言或标准 C++之前的编译器编写代码时才使用这种语法 旧式强制转换符号有下列两种形式 // C++强制转换符号 type (expr); // C 语言强制转换符号 (type) expr; 旧式强制转换可以用来代替标准 C++中的 static_cast cons_cast 或 reinterpret_cast 在标 准 C++之前 我们只能使用旧式强制转换 如果我们希望自己的代码在 C++和 C 语言中都能 够编译的话 那么只能使用 C 语言的强制转换符号 以下是一些使用了旧式强制转换符号的例子 const char *pc = (const char*) pcom; int ival = (int) 3.14159; extern char *rewrite_str( char* ); char *pc2 = rewrite_str( (char*) pc ); int addr_value = int( &ival ); 对旧式强制转换符号的支持是为了对 在标准 C++之前写的程序 保持向后兼容性 以 及提供与 C 语言兼容的符号 练习 4.21 已知下列定义 char cval; int ival; float fval; double dval; unsigned int ui; 指出可能发生的隐式类型转换 (a) cval = 'a' + 3; 154 第四章 表达式 (b) fval = ui - ival * 1.0; (c) dval = ui * fval; (d) cval = ival + fval + dval; 练习 4.22 已知下列的定义 void *pv; int ival; char *pc; double dval; const string *ps; 用强制转换符号重写下列每个语句 (a) pv = (void*)ps; (b) ival = int( *pc ); (c) pv = &dval; (d) pc = (char*) pv; 4.15 栈类实例 在关于递增递减操作符的讨论结束时 我们介绍了栈 stack 的抽象来说明这些操作符 的前置和后置格式 我们将用一个 iStack 类 即只支持 int 型元素的栈 的设计与实现的简 要过程来结束本章 栈是计算机科学的一个基本数据抽象 它允许以后进先出 LIFO 的顺序嵌入和获取 其中的值 栈的两个基本操作是 向栈中压入 push 一个新值 以及弹出 pop 或获取 最后压入的那个值 其他一些操作包括 查询栈是否满 full()或空 empty() 以及判断栈的 长度 size()——即包含多少个元素 我们的初始实现只支持 int 型的元素 下面是其公有接 口的声明 #include class iStack { public: iStack( int capacity ) : _stack( capacity ), _top( 0 ) {} bool pop( int &value ); bool push( int value ); bool full(); bool empty(); void display(); int size(); 155 第四章 表达式 private: int _top; vector< int > _stack; }; 为了演示递增递减操作符的前置和后置形式的用法 我们为 iStack 栈选择了固定长度的 实现 我们将在第 6 章结尾时将其修改为可动态增长的 我们把元素存储在一个 int 型的 vector 中 它的名字为_stack _top 含有下一个可用槽的值 push()操作会向该槽压入一个值 _top 的当前值反映了栈中元素的个数 同此 size()只需简单地返回_top inline int iStack::size() { return _top; }; 如果_top 等于 0 则 empty()返回 true 如果_top 等于_stack.size() 则 full()返回 true inline bool iStack::empty() { return _top ? false : true; } inline bool iStack::full() { return _top < _stack.size()-1 ? false : true; } 下面是如 pop()和 push()的实现代码 我们加入了输出函数来踉踪它们的执行情况 bool iStack::pop( int &top_value ) { if ( empty() ) return false; top_value = _stack[ --_top ]; cout << "iStack::pop(): " << top_value << endl; return true; } bool iStack::push( int value ) { cout << "iStack::push( " << value << " )\n"; if ( full() ) return false; _stack[ _top++ ] = value; return true; } 在练习 iStack 类之前 我们先来加入一个 display()函数 它允许我们查看栈的内容 给 定一个空栈 它的输出如下 ( 0 ) 156 第四章 表达式 对一个有 4 个元素 0 1 2 和 3 的栈 它产生如下输出 ( 4 ) ( bot: 0 1 2 3 :top ) 下面是我们的实现 void iStack::display() { if ( !size() ) { cout << "( 0 )\n"; return; } cout << "( " << size() << " )( bot: "; for ( int ix = 0; ix < _top; ++ix ) cout << _stack[ ix ] << " "; cout << " :top )\n"; } 下面的小程序使用了我们的类 for 循环迭代 50 次 它把每个偶数值 2 4 6 8 等等压 入栈中 当这些值是 5 的倍数时 比如 5 10 15 等等 就显示栈中的内容 当值是 10 的倍 数时 如 10 20 30 等等 它从栈中弹出最后两项 然后再次显示栈中的内容 #include #include "iStack.h" int main() { iStack stack( 32 ); stack.display(); for ( int ix = 1; ix < 51; ++ix ) { if ( ix%2 == 0 ) stack.push( ix ); if ( ix%5 == 0 ) stack.display(); if ( ix%10 == 0) { int dummy; stack.pop( dummy ); stack.pop( dummy ); stack.display(); } } } 编译并运行程序 产生下面的输出 ( 0 )( bot: :top ) iStack::push( 2 ) iStack::push( 4 ) ( 2 )( bot: 2 4 :top ) iStack::push( 6 ) 157 第四章 表达式 iStack::push( 8 ) iStack::push( 10 ) ( 5 )( bot: 2 4 6 8 10 :top ) iStack::pop(): 10 iStack::pop(): 8 ( 3 )( bot: 2 4 6 :top ) iStack::push( 12 ) iStack::push( 14 ) ( 5 )( bot: 2 4 6 12 14 :top ) iStack::push( 16 ) iStack::push( 18 ) iStack::push( 20 ) ( 8 )( bot: 2 4 6 12 14 16 18 20 :top ) iStack::pop(): 20 iStack::pop(): 18 ( 6 )( bot: 2 4 6 12 14 16 :top ) iStack::push( 22 ) iStack::push( 24 ) ( 8 )( bot: 2 4 6 12 14 16 22 24 :top ) iStack::push( 26 ) iStack::push( 28 ) iStack::push( 30 ) ( 11 )( bot: 2 4 6 12 14 16 22 24 26 28 30 :top ) iStack::pop(): 30 iStack::pop(): 28 ( 9 )( bot: 2 4 6 12 14 16 22 24 26 :top ) iStack::push( 32 ) iStack::push( 34 ) ( 11 )( bot: 2 4 6 12 14 16 22 24 26 32 34 :top ) iStack::push( 36 ) iStack::push( 38 ) iStack::push( 40 ) ( 14 )( bot: 2 4 6 12 14 16 22 24 26 32 34 36 38 40 :top ) iStack::pop(): 40 iStack::pop(): 38 ( 12 )( bot: 2 4 6 12 14 16 22 24 26 32 34 36 :top ) iStack::push( 42 ) iStack::push( 44 ) ( 14 )( bot: 2 4 6 12 14 16 22 24 26 32 34 36 42 44 :top ) iStack::push( 46 ) iStack::push( 48 ) iStack::push( 50 ) ( 17 )( bot: 2 4 6 12 14 16 22 24 26 32 34 36 42 44 46 48 50 :top ) iStack::pop(): 50 iStack::pop(): 48 ( 15 )( bot: 2 4 6 12 14 16 22 24 26 32 34 36 42 44 46 :top ) 158 第四章 表达式 练习 4.23 某些用户需要 peek()操作 以读出栈顶值 但并不把它从栈中移出 当然 条件是栈不 为空 请提供 peek()的实现 然后再在前面给出的 main()程序中加入语句 来练习 Peek()操 作 练习 4.24 我们的 iStack 设计的两个主要弱点是什么 怎样修正 5 语 句 程序最小的独立单元是语句 statement 在自然语言中 类似的结构是句子 与句子由句号结束一样 语句一般由分号结束 因此 一个表达式如 ival+5 给它 加个分号 就变成了一个简单语句 simple statement 复合语句 compound statement 是由一对花括号包围起来的一系列简单语句 在缺省情况下 语句以 其出现的顺序执行 但是 除了最简单的程序外 顺序的程序执行过程对于我们必 须要解决的问题来说是不够的 根据一个表达式的计算结果是 true 还是 false 特 殊的控制流 flow-of-control 程序语句允许有条件地或重复地执行一个简单语句 或复合语句 条件执行过程由 if if-else 和 switch 语句支持 而重复执行过程由 while do-while 和 for 语句支持 后三种语句通常也被称为循环 loop 语句 本 章将详细讨论 C++支持的程序语句类型 5.1 简单语句和复合语句 程序语句最简单的形式是空语句 形式如下 仅一个分号 ; // 空语句 空语句被用在 程序的语法上要求一条语句 而逻辑上却不需要 的时候 例如 在下 面的 while 循环中 把一个 C 风格字符串拷贝到另一个字符串中去所需的全部处理过程 在 这个语句的被称为条件 condition 的部分就已经完成了 但是 while 循环的格式要求条件 后面跟一条语句 因为不需要再做其他的工作 所以我们用一条空语句来满足这个语法要求 while ( *string++ = *inBuf++ ) ; // 空语句 意外出现的多余空语句不会产生编译错误 例如 下面的语句 ival = dval +sval;; // ok: 多余的空语句 由两条语句构成 向 ival 赋值的表达式语句以及空语句 简单语句由单个语句构成 例如 // 简单语句 160 第五章 语句 int ival = 1024; // 声明语句 ival; // 表达式语句 ival + 5; // 另一个表达式语句 ival = ival + 5; // 赋值语句 条件和循环语句在语法上只允许执行一条指定的相关语句 然而在实践中 这是远远不 够的 在逻辑上 程序经常需要执行两条或多条语句构成的序列 在这样的情况下 我们用 一个复合语句 compound 来代替单个语句 例如 if ( ival0 > ival1 ) { // 由一条声明和两条赋值语句构成的复合语句 int temp = ival0; ival0 = ival1; ival1 = temp; } 复合语句是由一对花括号括起来的语句序列 复合语句被视为一个独立的单元 它可以 出现在程序中任何单个语句可以出现的地方 复合语句不需要用分号作为结束 这是一种附 加的语法上的便利 空复合语句与空语句等价 它为空语句提供了一种替代语法 例如 while ( *string++ = *inBuf++ ) { } // 等价于空语句 包含一条或多条声明语句的复合语句 如前面的例子 也称为块 block 或语句块 statement block 块引入了程序中的局部域 在块中声明的标识符 如前面例子中的 temp 只在该块中可见 块 域以及对象的生命期将在第 8 章中详细讨论 5.2 声明语句 在 C++中 对象的定义 如 int ival; 被视为 C++语言的一条语句 称作声明语句 declaration statement 尽管在这种情况 下称为定义 definition 语句更准确 一般它可以被放在程序中任何允许语句出现的地方 例如 考虑下面的程序 声明语句用// #n 编号 这里 n 从 1 开始计数 #include #include #include int main() { string fileName; // #1 cout << "Please enter name of file to open: "; cin >> fileName; 161 第五章 语句 if ( fileName.empty() ) { // 很好 但有一点要说明 cerr << "fileName is empty(). bailing out. bye!\n"; return -1; } ifstream inFile( fileName.c_str() ); // #2 if ( ! inFile ) { cerr << "unable to open file. bailing out. bye!\n"; return -2; } string inBuf; // #3 vector< string > text; // #4 while ( inFile >> inBuf ) { for ( int ix = 0; ix < inBuf.size(); ++ix ) // #5 // 这里 ch 并不必需, // 但有利于说明问题 if (( char ch = inBuf[ix] ) == '.' ) { // #6 ch = '_'; inBuf[ix] = ch; } text.push_back( inBuf ); } if ( text.empty() ) return 0; // 一条声明语句, 有两个定义 vector::iterator iter = text.begin(), // #7 iend = text.end(); while ( iter != iend ) { cout << *iter << '\n'; ++iter; } return 0; } 程序包含七条声明语句和八个对象定义 声明语句展示了声明的局部性 locality of declaration ——即 声明语句出现在被定义对象首次被使用的局部域内 在 70 年代 计算机程序语言设计哲学强调这样一种美德 在程序 函数或语句块的开始 处 并且在其他程序语句之前定义全部对象 例如 在 C 中 对象的定义并不被视为 C 语 言的语句 块中的所有对象定义必须出现在任何程序语句之前 出于这种需要 C 程序员使 自己习惯于在每个当前块的顶部定义全部对象 在某种程度上 这是 FORTRAN 支持的动 态对象定义容易出错的一种回应措施 由于对象的定义是 C++语言的一条语句 所以可以将对象定义放在任何其他语句能够出 现的地方 从语法上讲 这也是使声明的局部件成为可能的原因 这是必需的吗 对于内置类型 如整型和浮点型 声明的局部性主要是个人的喜好问题 C++允许在 if else-if switch while 和 for 循环的条件部分出现声明 前面给出的程序中有 两个这样的例子 以此来鼓励使用局部声明 喜欢声明局部性的人相信它使程序更易于理 162 第五章 语句 解 对于类对象的定义来说 由于类对象与构造函数和析构函数相关联 所以声明的局部性 就变成必需的了 当把这些对象放在函数或语句块的开始时 发生下面两件事情 1 在做函数或语句块中的任何事情之前 所有类对象的构造函数均被调用 声明的局部 性使我们能够把初始化的开销分摊到函数或语句块中 2 或许更重要的是 通常情况下 在函数或语句块内部的所有程序语句都被执行之前 该函数或者语句块就结束了 例如 前面的程序显示了两个非正常终止点 获取文件名失败 和打开用户指定的文件失败 在成功地经过这些终止点之前定义类对象 如 inBuf 和 text 会导致执行不必要的构造函数——析构函数对 如果给出足够多的类对象或者需要大量计算 的构造函数和析构函数 这将对程序的运行效率产生不必要的影响 虽然结果仍然是正确的 但是有时候性能可能会变得不可接受 专业的 C 程序员习惯把对象定义放在函数或语句块 开始的地方 有时候他们会发现自己的 C++程序比等价的 C 程序性能低得多 这就是原因所 在 一条声明语句可以由一个或多个对象定义构成 例如 在我们的程序中 在同一条声明 语句中定义了两个向量迭代器 // 一条声明语句, 两个对象定义 vector::iterator iter = text.begin(), iend = text.end(); 它与下面一对声明是等价的 // 等价于两条声明语句 vector::iterator iter = text.begin(); vector::iterator iend = text.end(); 虽然说 选择哪一种声明方案是个人喜好问题 但是 当我们把对象 指针以及引用混 合在一起时 在一条声明语句中放置多个对象定义更容易出错 例如 下列声明语句就很不 清楚 用户是想定义一个指针和一个对象 还是把第二个指针错写成了对象 标识符名暗示 第二个定义是错误的 // 符合程序员的意图吗 string *ptr1, ptr2; 在这种情况下 独立的声明语句几乎没有为错误留下空间 string *ptr1; string *ptr2; 在我们自己的代码中 我们倾向于根据对象的用法将其分组 例如 在下面两个声明语 句中 int aCnt=0, eCnt=0, iCnt=0, oCnt=0, uCnt=0; int charCnt=0, wordCnt=0; 那些被用作记录五个英语元音字母个数的对象被放在一条声明语句中 记录全部字符个 数和单词个数的对象则被放在第二条声明语句中 虽然这种方法在我们看起来非常明智 但 是逻辑上我们不能认为它更正确或者更合适 163 第五章 语句 练习 5.1 假设你在领导一个小的程序项目组 你希望所有代码都遵循统一的声明规则 请明确定 义你希望项目组遵循的声明规则 并说明理由 练习 5.2 假设你被分到了练习 5.1 的项目组 你对于已经宣布的声明策略并不赞同 而且你也不 同意任何一条声明策略 请明确说明并证明你的理由 5.3 if 语句 C++语言提供 if 语句的动机是 根据指定的表达式是否为 true 有条件地执行一条语句 或语句块 if 语句的语法形式如下 if ( condition ) statement condition 条件 必须被放在括号内 它可以是表达式 如 if ( a + b > c ) { ... } 或是一条具有初始化功能的声明语句 如 if ( int ival = compute_value() ) { ... } 在 condition 中定义的对象 只在与 if 相关的语句或语句块中可见 例如 试图在 if 语 句后面访问 ival 会导致编译错误 if ( int ival = compute_value() ) { // ival 只在这个 if 语句块中可见 } // 错误: ival 不可见 if ( ! ival ) ... 为了说明 if 语句的用法 让我们来实现一个函数 min() 它返回一个 int 型 vector 中的最 小值 另外 它还记录了该最小值在 vector 中出现的次数 对于 vector 中的每个元素 我们 需分做以下几件事情 把元素同当前最小值作比较 如果它小于最小值 则把它赋给最小值 将计数器复位为 1 如果它等于最小值 把计数器加 1 否则 什么都不做 检查完所有元素后 将最小值及其计数器返回给用户 这需要两条 if 语句 if ( minVal > ivec[ i ] ) ... // 新的 minVal if ( minVal == ivec[ i ] ) ... // 又一次出现 在使用 if 语句时 一个比较普遍的错误是 当条件为 true 并且必须执行多条语句时 164 第五章 语句 我们往往忘记了提供复合语句 找到这样的错误比较困难 因为程序代码看起来是正确的 例如 if ( minVal > ivec[ i ] ) minVal = ivec[ i ]; occurs = 1; // 不是 if 语句的组成部分 它的意思与程序员的意图以及程序的缩进相反 occurs = 1; 没有被当作 if 语句的一部分 因此 最小值的出现计数总是 1 下面是按程序员意图写 的 if 语句 左花括号的确切位置是一个无休止的争论话题 if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; } 第二个 if 语句如下 if ( minVal == ivec[ i ] ) ++occurs; 注意 if 语句的顺序很重要 如果按如下顺序放置语句 则函数的结果总是差 1 if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; } // 如果 minVal 正好已经被设置为 ivec[i], // 则存在潜在的错误 if ( minVal == ivec[ i ] ) ++occurs; 针对同一个值的两个 if 语句执行起来不但危险 而且也没有必要 同一个元素不能既等 于 minVal 同时又小于 minVal 如果一个条件为 true 则另一个就可以被安全地忽略了 if 语句通过提供 else 子句以便允许这种 如果 否则 条件的情况 if-else的语法形式如下 if ( condition ) statement1 else statement2 如果 condition 为 true 则 statement1 语句 1 被执行 否则执行 statement2 语句 2 例如 if ( minVal == ivec[ i ] ) ++occurs; else if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; } 165 第五章 语句 在本例中 statement2 本身又是一个 if 语句 如果 minVal 小于这个元素 则什么也不做 在下面的例子中 将执行三个语句之一 if ( minVal < ivec[ i ] ) {} // 空语句 else if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; } else // minVal == ivec[ i ] ++occurs; if-else语句引入了一种二义性问题 称为空悬 else dangling-else 问题 这种问题出现在 当 if 子句多于 else 子句时 问题是 这些 else 子句分别和哪一个 if 子句匹配 例如 if ( minVal <= ivec[ i ] ) if ( minVal == ivec[ i ] ) ++occurs; else { minVal = ivec[ i ]; occurs = 1; } 程序的缩进形式表明程序员相信 else 应该与最外面的 if 子句匹配 然而在 C++中 空悬 else 二义性由以下规定来解决 else 子句与 最后出现的未被匹配的 if 子句 相匹配 在本 例中 if-else 语句实际的计算过程如下 if ( minVal <= ivec[ i ] ) { // 空悬 else 的解释结果 if ( minVal == ivec[ i ] ) ++occurs; else { minVal = ivec[ i ]; occurs = 1; } 要想改变这种缺省的空悬 else 匹配效果 一种方法是把后来出现的 if 放在复合语句中 if ( minVal <= ivec[ i ] ) { if ( minVal == ivec[ i ] ) ++occurs; } else { minVal = ivec[ i ]; occurs = 1; } 有些编码风格建议总是使用复合语句括号 以避免在以后修改代码时可能出现的混淆或 错误 下面是我们的 min()函数的第一次迭代 第二个参数 occurs 将记录最小值出现的次数 我们将在函数中设置它 同时确定并返回实际的最小值 我们用 for 循环来迭代全部元素 不幸的是 我们的实现中包含了一个逻辑错误 你能看出来吗 #include int min( const vector &ivec, int &occurs ) { int minVal = 0; occurs = 0; 166 第五章 语句 int size = ivec.size(); for ( int ix = 0; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; else if ( minVal > ivec[ ix ] ) { minVal = ivec[ ix ]; occurs = 1; } } return minVal; } 一般地 函数只返回一个值 但是我们需要返回 vector 中包含的最小值以及它在 vector 中出现的次数 在我们的实现中 增加了一个引用参数 通过它来传送第二个值 见 7.3 节 关于引用参数的讨论 在 min()中 任何针对 occurs 的赋值都作用在作为参数传递进来的 实际对象上 例如 int main() { int occur_cnt = 0; vector< int > ivec; // ... 填充 ivec // occur_cnt 存有在 min()中设置的出现次数值 int minval = min( ivec, occur_cnt ); // ... } 一种替代解决方案是用一个 pair 对象 见 3.14 节关于 pair 类型的讨论 它拥有两个整 型对象 最小值和出现次数 然后函数返回这种 pair 对象的一个实例 例如 // 另外一种实现代码 // 返回一个 pair 对象 #include #include typedef pair min_val_pair; min_val_pair min( const vector &ivec ) { int minVal = 0; int occurs = 0; // 同上 直到 return return make_pair( minVal, occurs ); } 167 第五章 语句 不幸的是 在这两种方案中 我们的 min()函数实现都是不正确的 你能看出问题来吗 对 因为我们把 minVal 初始化为 0 如果数组的最小值大于 0 则我们的实现就不能找到它 只能返回 0 同时 occurs 被设置为 0 minVal的最好初值是数组的第一个元素 int minVal = ivec[0]; 它保证总是返回数组中的最小值 虽然这样做修正了程序中的 bug 但是 它也引入了 一点小小的性能损失 下面是代码中不合适的部分 你能看出这点小小的性能代价是什么吗 // min()函数被修改后的开始部分 // 不幸的是 它引入了一点小小的性能损失 int minVal = ivec[0]; occurs = 0; int size = ivec.size(); for ( int ix = 0; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; // ... 因为 ix 被初始化为 0 所以循环的第一次迭代总是发现 minVal 等于 ivec[0] 因为我们 正是用它来初始化 minVal 的 通过将 ix 初始化为 1 可以避免不必要的比较操作以及对 minVal 的再次赋值 这显然是个改善 但不幸的是 这又向程序引入了另外一个错误 或许 早就应该让事情恢复它本来的面貌 你能看出修改后的程序有什么问题吗 // 修改后的 min()的开始部分 // 不幸的是 它引入了一个错误 int minVal = ivec[0]; occurs = 0; int size = ivec.size(); for ( int ix = 1; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; // ... 如果 ivec[0]是最小值 则 occurs 永远不会被设为 1 当然 这很容易改正 而且 以后 我们会看到这样做完全有必要 int minVal = ivec[0]; occurs = 1; 不幸的是 这还不是完全正确的 如果用户偶然传递了一个空的 vector 又会产生什么 后果 试图访问空 vector 的第一个元素是不正确的 有可能导致运行时刻程序错误 我们必 须对这种可能性提供保护 一种方案如下 另外一种可能的替代方案是 返回布尔值表明函 168 第五章 语句 数是否成功 int min( const vector &ivec, int &occurs ) { int size = ivec.size(); // 处理空 vector 异常 // occurs 被设置为 0 表示空 vector if ( ! size ) { occurs = 0; return 0; } // ok: vector 至少含有一个元素 int minVal = ivec[ 0 ]; occurs = 1; for ( int ix = 1; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; else if ( minVal > ivec[ ix ] ){ minVal = ivec[ ix ]; occurs = 1; } } return minVal; } 空 vector 问题的另一种替代方案是 让 min()函数在通过引用返回最小值的同时 返回一 个布尔值来表明失败还是成功 // 空 vector 问题的另一种方案 bool min( const vector< int > &ivec, int &minVal, int &occurs ); 另一种设计选择是让 min()函数在接收到空 vector 时 抛出一个异常 见第 11 章关于 异常处理的讨论 不幸的是 类似这样的错误非常普遍 作为程序员 我们将会在某个地方犯错误——有 时甚至是很愚蠢的错误 如果错误发生了 最重要的是接受发生错误的事实 并保持警惕 性 在条件允许的情况下尽可能严格地测试和检查代码 对于简单的 if-else 语句 条件操作符可以提供一个便捷的简写形式 例如 下列 min() 函数模板 template inline const valueType& min( valueType &val1, valueType &val2 ) { if ( val1 < val2 ) return val1; return val2; } 可以写成 template inline const valueType& 169 第五章 语句 min( valueType &val1, valueType &val2 ) { return ( val1 < val2 ) << val1 : val2; } 连接成一长串的 if-else 语句 比如下列语句 修改的时候我们很难读懂而且容易出错 if ( ch == 'a' || ch == 'A' ) ++aCnt; else if ( ch == 'e' || ch == 'E' ) ++eCnt; else if ( ch == 'i' || ch == 'I' ) ++iCnt; else if ( ch == 'o' || ch == 'O' ) ++oCnt; else if ( ch == 'u' || ch == 'U' ) ++uCnt; 这种 if-else 语句串的替代结构是 switch 语句 只要与测试量作比较的值是常量表达式 比如上面代码中被测试的字符常量 我们就可以用 switch 语句来替代 switch 语句是下一节 的话题 练习 5.3 改正下列代码 (a) if ( ival1 != ival2 ) ival1 = ival2 else ival1 = ival2 = 0; (b) if ( ival < minval ) minval = ival; occurs = 1; (c) if ( int ival = get_value()) cout << "ival = " << ival << endl; if ( ! ival ) cout << "ival = 0\n"; (d) if ( ival = 0 ) ival = get_value(); (e) if ( ival == 0 ) else ival = 0; 170 第五章 语句 练习 5.4 把 min()函数参数表中的 occurs 声明改成非引用参数类型 并重新运行程序 程序的行 为如何变化 5.4 switch 语句 深层嵌套的 if-else 语句常常在语法上是正确的 但逻辑上却没有正确地表达程序员的意 图 例如 意料之外的 else-if 更可能不会注意到而被溜过去 修改这些语句非常困难 C++ 提供了 switch 语句 作为一种 在一组互斥的项目中做选择 的替代方法 为了解释 switch 语句的用法 我们考虑下面的问题 要求记录每个元音字母在随机的文 本段中出现的次数 习惯上认为英语中元音 e 出现的次数最多 程序逻辑如下 按顺序读取每个字符直到没有字符为止 把每个字符同元音字母集合作比较 如果字符同某个元音字母匹配 则该元音计数加 1 显示结果 用此程序分析本书英文版的第一节 下面是输出 它证实了习惯的看法 元音 e 的出现 频率最高 aCnt: 394 eCnt: 721 iCnt: 461 oCnt: 349 uCnt: 186 switch语句由以下部分构成 1 关键字 switch 后面是一个要被计算的表达式 表达式被放在括号中 在本例中 表 达式是被读取的字符 例如 char ch; while ( cin >> ch ) switch( ch ) 2 一组 case 标签 label 它由关键字 case 后接一个常量表达式及其冒号构成 此常 量表达式将被用来与 switch 表达式的结果做比较 在本例中 每个 case 标签代表一个元音字 母 例如 case 'a': case 'e': case 'i': case 'o': case 'u': 3 与一个或一组 case 标签相关联的语句序列 例如 为了累加每个元音字母的出现计 数 我们提供了一个将元音字母计数递增 1 的赋值表达式 4 可选的 default 标签 default 标签也被看作是一种 else 子句 如果 switch 表达式与任 意一个 case 标签都不匹配 则 default 标签后面的语句被计算 例如 如果我们希望计算非 171 第五章 语句 元音字母的个数 则可以增加如下 default 标签和语句 default: // 任何非元音字母 ++non_vowel_cnt; 关键字 case 后面的值必须是一种整数类型的常量表达式 例如 下列语句将导致编译错 误 // 非法的 case 标签值 case 3.14: // 非整数 case ival: // 非常量 另外 任意两个 case 标签不能有同样的值 如果有 则导致编译错误 switch表达式可以是任意复杂的表达式 包括函数调用的返回值 它的值与每个 case 标 签相关联的值作比较 直到某个匹配成功或全部标签比较完毕 如果匹配到了某个标签 则 程序从其后的语句继续执行 如果所有标签都不匹配 那么若有 default 标签的话 则从 default 后面的语句继续执行 否则程序从 switch 语句后面的第一条语句继续执行 普遍的误解是 只有与被匹配的 case 标签相关联的语句才被执行 实际上 程序从该点 开始执行并继续越过 case 边界直到 switch 语句结束 如果这一点弄错了 那么可以肯定 我 们的程序也是错误的 例如 下面记录元音字母个数的 switch 程序的实现就是不正确的 #include int main() { char ch; int aCnt=0, eCnt=0, iCnt=0, oCnt=0, uCnt=0; while ( cin >> ch ) // 警告: 这是不正确的 switch ( ch ) { case 'a': ++aCnt; case 'e': ++eCnt; case 'i': ++iCnt; case 'o': ++oCnt; case 'u': ++uCnt; } 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 << '\n'; } 如果 ch 被设置为 i 则程序从 case i 后面的语句开始执行 iCnt 递增 但是 程序的 执行并没有在那里停止 而是越过 case 边界继续执行 直到 switch 语句的结束花括号 oCnt 和 uCnt 也都被递增 如果下一次 ch 被设置为 e 则 eCnt iCnt oCnt 和 uCnt 也都将被递增 172 第五章 语句 程序员必须显式地告诉编译器停止执行 switch 中的语句 这可以通过在 switch 语句内的 每个执行单元后指定一个 break 语句来完成 在大多数条件下 一个 case 标签的最后一条语 句是 break 当遇到 break 语句时 switch 语句被终止 控制权被转移到紧跟在 switch 结束花括号后 面的语句上 在我们的程序中 控制权被传递给输出语句 正确的 switch 语句如下 switch ( ch ) { case 'a': ++aCnt; break; case 'e': ++eCnt; break; case 'i': ++iCnt; break; case 'o': ++oCnt; break; case 'u': ++uCnt; break; } 在大多数情况下 故意省略 break 语句的 case 标签应该提供一条注释 以指明这种省略 是故意的 我们的程序不但应该能够编译执行 而且对于以后负责修改和扩展的程序员来说 也应该是可读的 与预期用法相反的代码尤其难以理解 因为我们常常不能确定这种不合常 理的行为是故意的 正确的 还是疏忽的 错误的 在这种情况下 说明程序员意图的注释 就增强了程序的可维护性 程序员什么时候希望省略 break 语句 允许程序执行多个 case 标签 一种情况是 两个 或多个值由相同的动作序列来处理 这是必要的 因为一个 case 标签只能与一个值相关联 因此 为了指示出一个范围 典型的做法是 我们把 case 标签一个接一个堆起来 例如 如 果我们只希望记录元音字母的总数 而不是每一个元音字母的个数 那么我们可以这样写 int vowelCnt = 0; // ... switch ( ch ) { // a, e, i, o, u 的任何出现都使 vowelCnt 递增 case 'a': case 'e': case 'i': case 'o': case 'u': ++vowelCnt; break; } 有些程序员喜欢把 case 标签捆绑在一起 以强调这种情形代表的是一个要被匹配的范 围 173 第五章 语句 switch ( ch ) { // 另外一种合法的语法 case 'a': case 'e': case 'i: case 'o': case 'u': ++vowelCnt; break; } 当前实现的元音字母计数程序有个问题 例如 程序怎样处理下列输入 UNIX 大写的 U 和 I 不能被识别为元音字母 我们的程序不能记录大写元音字母出现的次数 下面是修正后的 switch 语句 也使用了省略 break 的用法 switch ( ch ) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; } default标签给出了无条件 else 子句的等价体 如果所有的 case 标签与 switch 表达式的值 都不匹配 并且 default 标签也存在 则执行 default 标签后面的语句 例如 给我们的程序 增加 default 的情形 使它能够记录辅音字母的个数 #include #include int main() { char ch; int aCnt=0, eCnt=0, iCnt=0, oCnt=0, uCnt=0, consonantCnt = 0; while ( cin >> ch ) switch ( ch ) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': 174 第五章 语句 ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; default: if ( isalpha( ch )) ++consonantCnt; break; } 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 << '\n' << "Number of consonants: \t" << consonantCnt << '\n'; } isalpha()是标准 C 库的一个例程 如果它的参数是一个英文字母 则返回值为 true 为 了使用它 程序员必须包含系统头文件 ctype.h 我们将在第 6 章详细介绍 ctype.h 例程 尽管在 switch 语句的最后一个标签中指定 break 语句不是严格必需的 但是为安全起见 我们最好总是提供一个 break 语句 如果后来在 switch 语句的末尾又加了另外一个 case 标签 那么原来最后一个标签现在已不是最后一个了 那么如果它缺少 break 的话 也将导致执行 两个 case 标签中的所有语句 声明语句也可以被放在 switch 语句的条件中 如下所示 switch( int ival = get_response() ) ival被初始化 并且读初始化值成为与每个 case 标签作比较的值 ival 在整个 switch 语 句中是可见的 但在其外面并不可见 把一条声明语句放在与 case 或 default 相关联的语句中是非法的 除非它被放在一个语 句块中 例如 下列代码将导致编译时刻错误 case illegal_definition: // 错误: 声明语句必须被放在语句块中 string file_name = get_file_name(); // ... break; 如果一个定义没有被包围在一个语句块中 那么它在 case 标签之间是可见的 但是只有 当定义它的 case 标签被执行时 它才能被初始化 因此需要一个语句块来保证名字是可见的 并且也只有这个语句块才能够使用它 而且可以保证 它只在这个语句块中才能被初始化 为了使我们的程序能通过编译 必须引入语句块 重新实现 case 标签如下 case ok: { // ok: 声明语句被放在语句块中 175 第五章 语句 string file_name = get_file_name(); // ... break; } 练习 5.5 请修改元音字母计数程序 使它能够记录被读取到的空格 TAB 键以及换行符的个数 练习 5.6 请修改元音字母计数程序 使它能够记录下列双字符序列出现的次数 ff fl 和 fi 练习 5.7 下面每段代码都暴露了使用 switch 语句的一个普遍错误 请指出并修改这些错误 (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++; 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: 176 第五章 语句 bufsize = jval * sizeof( int ); break; case kval: bufsize = kval * sizeof( int ); break; } (e) enum { illustrator = 1, photoshop, photostyler = 2 }; switch (ival) { case illustrator: --illus_license; break; case photoshop: --pshop_license; break; case photostyler: --pstyler_license; break; } 5.5 for 循环语句 我们已经看到 大量程序都会涉及到在某个条件保持为真时重复执行一组语句 例如 当没有到达文件尾时 读入并处理文件的下一个元素 对于每个不等于 vector 末元素下一位 置的索引 获取并处理 vector 的元素 等等 C++提供了三种循环控制语句 以支持当某个 特定的条件保持为真时 重复执行一个语句或语句块 我们已经看到了 for 和 while 循环的大 量例子 for和 while 循环首先进行条件的真值测试 这意味着这两个循环都可以在相关语句或语 句块还没有被执行的情况下就终止了 第三种循环结构 do-while 循环 保证语句或语句块 至少被执行一次——在这些语句被执行之后进行条件测试 在本节中 我们将详细了解 for 循环 while 循环是 5.6 节的话题 而 do-while 循环将在 5.7 节中介绍 for循环最普遍的用法是遍历一个定长的数据结构 如数组或 vector 向量 例如 #include int main() { int ia[10]; for ( int ix = 0; ix< 10; ++ix ) ia[ ix ] = ix; vector ivec( ia, ia+10 ); vector::iterator iter = ivec.begin(); for ( ; iter != ivec.end(); ++iter ) *iter *= 2; 177 第五章 语句 return 0; } for循环的语法形式如下 for ( init-statement; condition; expression ) statement init-statement 初始化语句 可以是声明语句或表达式 一般地 它被用来对一个在循 环过程中被递增的变量进行初始化 或者赋给一个起始值 如果不需要初始化或者它已经在 别处出现 则可以省略 init-statement 比如前面例子中的第二个 for 循环 但是 必须要有一 个分号表明缺少该语句 或给出空语句 下面都是合法的 init-statement // 假设 index 和 iter 已经在别处定义 for ( index = 0; ... for ( ; /* 空的初始化语句 */ ... for ( iter = ivec.begin(); ... for ( int lo = 0, hi = max; ... for ( char *ptr = getStr(); ... condition 条件语句 用作循环控制 condition 计算结果为 true 多少次 则 statement 语 句 就执行多少次 statement 可以是单个语句 也可以是复合语句 如果 condition 的第一次 计算结果为 false 则 statement 从不会被执行 下面都是合法的 condition 实例 (...; index < arraySize; ... ) (...; iter != ivec.end(); ... ) (...; *st1++ = *st2++; ... ) (...; char ch = getNextChar(); ... ) expression 表达式 在循环每次迭代后被计算 一般用它来修改在 init-statement 中被初 始化的 在 condition 中被测试的变量 如果 condition 的第一次计算结果为 false 则 expression 从不会被计算 下面都是 expression 的合法实例 ( ...; ...; ++index ) ( ...; ...; ptr = ptr->next ) ( ...; ...; ++i, --j, ++cnt ) ( ...; ...; ) // null instance 已知下列 for 循环 const int sz = 24; int ia[ sz ]; vector ivec( sz ); for ( int ix = 0; ix< sz; ++ix ) { ivec[ ix ] = ix; ia[ ix ] = ix; } 它的计算顺序如下 1 在循环开始 执行一次 init-statement 在本例中 ix 被初始化为 0 178 第五章 语句 2 执行 condition 如果它的计算结果为 true 则执行复合语句 本例中 只要 ix 小于 sz ix 就被赋给 ivec[ix]和 ia[ix] 而 false 条件将终止循环 初始的 false 条件将导致复合语 句从不被执行 3 执行 expression 典型情况下 它修改在 init-statement 中被初始化 在 condition 中被 测试的变量 在本例中 ix 被递增 1 这三步代表了一个完整的 for 循环迭代 现在重复第 2 步 然后是第 3 步 直到 condition 为 false 即 ix 不再小于 sz 为止 在 init-statement 中可以定义多个对象 但只能出现一个声明语句 因此 所有对象都必 须是相同的类型 例如 for ( int ival = 0, *pi = &ia, &ri = val; ival< size; ++ival, ++pi, ++ri ) // ... 在 for 循环的 condition 条件部分 中定义的对象很难管理 它的最终计算结果必须为 false 否则循环将水远不会终止 下面是个例子 它看起来有点不太自然 #include int main() { for ( int ix = 0; bool done = ix == 10; ++ix ) cout << "ix: " << ix << endl; } 在 for 循环 condition 中定义的对象的可视性局限在 for 循环体内 例如 for 循环后面对 iter 的测试是个编译时刻错误11 int main() { string word; vector< string > text; // ... for ( vector< string >::iterator iter = text.begin(), iter_end = text.end(); iter != text.end(); ++iter ) 11 在标准 C++之前 定义在 init-statement 中的对象的可视性可以扩展到 for 循环包含的语句块之外 例如 给定下面两个 for 循环 它们位于同一个语句块之中 { // 在标准 C++中合法 但是在标准 C++之前 ival 被定义两次 所以不合法 for ( int ival = 0; ival < size; ++ival ) // ... for ( int ival = size -1; ival >= 0; --ival ) // ... } 在标准 C++之前 ival 被标记为编译时刻错误 重复定义 然而在标准 C++中 jval 的两个实例都是局部 的 仅限于它们各自的 for 循环 所以下述程序逻辑是合法的 179 第五章 语句 { if ( *iter == word ) break; // ... } // 错误: iter 和 iter_end 不可见 if ( iter != iter_end ) // ... } 练习 5.8 下列哪些 for 循环是错误的 (a) for ( int *ptr = &ia, ix = 0; ix< size && ptr != ia+size; ++ix, ++ptr ) // ... (b) for ( ; ; ) { if ( some_condition ) break; // ... } (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 ) // ... 练习 5.9 假设你被邀请写一个 for 循环的风格指导 它将被用在项目组范围内 解释并举例说明 你的用法规则 如果有三部分 则分别加以说明 如果你强烈反对用法规则 至少对于 for 循环来说如此 则解释并举例说明原因 练习 5.10 已知函数声明 bool is_equal( const vector &v1, const vector &v2 ); 写出函数体 用它来确定两个 vector 是否相等 对长度不同的两个 vector 只比较较小 180 第五章 语句 的 vector 的元素数目 例如 给出向量 (0,1,1,2) 和 (0,1,1,2,3,5,8) is_equal()返回 true 而 v1.size()和 v2.size()返回 vector 的长度 5.6 while 语句 while循环的语法形式如下 while ( condition ) statement condition 条件 计算结果为 true 多少次 则循环就迭代多少次 语句或语句块也被执 行多少次 执行序列如下 1 计算 condition 2 如果 condition 为 true 则执行 statement 语句 如果 condition 的第一次计算结果为 false 则 statement 永远不会被执行 while循环的 condition 可以是表达式 如 bool quit = false; // ... while ( ! quit ) { // ... quit = do_something(); } string word; while ( cin >> word ) { ... } 或是初始化定义 如 while ( symbol *ptr = search( name )) { // do something } 在后一个例子中 ptr 只在与 while 循环相关联的语句块内可见 如同在 if switch 及 for 语句中一样 下面是 while 循环的一个例子 它迭代了一个由一对指针指向的元素集合 int sumit( int *parray_begin, int *parray_end ) { int sum = 0; if ( ! parray_begin || ! parray_end ) return sum; while ( parray_begin != parray_end ) // 把当前元素的值加到 sum 上 // 然后增加指针 使其指向下一个元素 sum += *parray_begin++; return sum; } int ia[6] = { 0, 1, 2, 3, 4, 5 }; 181 第五章 语句 int main() { int sum = sumit( &ia[0], &ia[ 6 ] ); // ... } 为了使 sumit()能够正确地执行 两个指针必须指向同一个数组的元素 parray_end 可以 安全地指向数组最末元素的下一个位置 如果不是这样 我们就说 sumit()的行为是未定义 的 最好的情况下它会返问一个没有意义的结果 不幸的是 在 C++中我们没有办法保 证两个指针都指向同一个数组 正如在第 12 章中将要看到的 C++标准库在实现泛型算法时 这些算法接收两个指针 分别指向一个容器的首尾元素 练习 5.11 下列 while 循环声明哪些是错误的 (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 ( word.empty() ) break; // ... } if ( ! status ) cout << "Did not find any words\n"; 练习 5.12 while 循环尤其擅长在某个条件保持为真时不停地执行 例如 当没有到达文件尾时 有读取下一个值 一般认为 for 循环是一种按步循环 用一个索引按步遍历集合中一定范围 内的元素 按每个循环的习惯用法编写程序 然后再用另外一种结构重新编写 如果你只能 用一种循环编写程序 你会选择哪种结构 为什么 练习 5.13 写一个小程序 从标准输入读入字符串序列 直到出现相同的词或者所有的词都已经被 输入 用 while 循环每次读入一个单词 如果连续出现相同的词 则用 break 语句终止循环 如果出现重复 则输出重复的词 否则输出消息说明没有重复的词 182 第五章 语句 5.7 do while 语句 假设我们被要求写一个交互程序 实现将英里转换成公里 程序大致如下 int val; bool more = true; // 为启动循环而设置的哑元 while ( more ) { val = getValue(); val = convertValue(val); printValue(val); more = doMore(); } 现在的问题是 循环控制是在循环体内被设置的 但是 对于 for 和 while 循环来说 除 非循环条件计算结果为真 否则循环体将永不被执行 这意味着我们必须给出第一个值来启 动循环 另一种更好的方案是 我们可以使用 do while 循环 do while 循环保证至少执行一 次 statement do while 循环的语法形式如下 do statement while ( condition ); statement在 condition 被计算之前执行 如果 condition 的计算结果为 false 则循环终止 前面的程序现在看起来如下 do { val = getValue(); val = convertValue(val); printValue(val); } while ( doMore() ); 不像其他循环语句 do while 循环的条件 即 condition 部分 不支持对象定义——即 不能写 // 错误: 在 do 循环的 condition 部分不支持声明语句 do { // ... mumble( foo ); } while ( int foo = get_foo() ) // 错误 因为只有在语句或语句块首先被执行之后 条件部分才被计算 练习 5.14 下列 do while 循环 哪些是错误的 (a) do string rsp; int val1, val2; 183 第五章 语句 cout << "please enter two values: "; cin >> val1 >> val2; cout << "The sum of " << val1 << " and " << val2 << " = " << val1 + val2 << "\n\n" << "More<< [yes][no] "; cin >> rsp; while ( rsp[0] != 'n' ); (b) do { // ... } while ( int ival = get_response() ); (c) do { int ival = get_response(); if ( ival == some_value() ) break; } while ( ival ); if ( !ival ) // ... 练习 5.15 写一个小程序 实现从用户处得到两个字符串 然后输出两者的比较结果 按字典顺序 即字母顺序输出哪个字符串小于另一个字符串 继续要求用户输入 直到用户请求退出为 止 请使用 string 类型 string 的小于操作符以及 do while 循环大成 5.8 break 语句 break语句终止最近的 while do while for 或 switch 语句 程序的执行权被传递给紧接 着被终止语句之后的语句 例如 下面的函数在一个整数数组中查找一个特殊值的首次出现 如果找到 则返回它的索引值 否则返回-1 实现代码如下 // value 是否在 ia 中? 若是 则返回索引位置; 否则返回-1 int search( int *ia, int size, int value ) { // 确保 ia != 0 以及 size > 0 ... int loc = -1; for ( int ix = 0; ix< size; ++ix ) { if ( value == ia[ ix ] ) { // ok: 找到了! // 设置好位置, 然后离开循环 loc = ix; break; } } // for 循环结束处 // break 语句跳转到这里 184 第五章 语句 return loc; } 在本例中 break 终止了 for 循环 执行权被转交给紧随其后的 return 语句 在这个例子中 break 终止了包含它的 for 循环 而不是它外边的 if 语句 如果一个 break 语句出现在 if 语句的 内 部 但是并不被包含在 switch 或循环语句中 那么这样的 break 语句将导致编译错误 例如 // 错误: break 语句的非法出现 if ( ptr ) { if ( *ptr == "quit" ) break; // ... } 一般来说 break 语句只能出现在循环或 switch 语句中 当 break 出现在嵌套的 switch 或循环语句中时 里层的 switch 或循环语句被终止并不影 响外层的 switch 或循环 例如 while ( cin >> inBuf ) { switch( inBuf[ 0 ] ) { case '-': for ( int ix = 1; ix< inBuf.size(); ++ix ) { if ( inBuf[ ix ] == ' ' ) break; // #1 // ... // ... } break; // #2 case '+': // ... } } 由//#1 标记的 break 终止了 case 标签连字符 - 内的 for 循环 但没有终止 switch 语句 类似地 由//#2 标记的 break 终止了 inBuf 第一个字符的 switch 语句 但没有终止外层 的 while 循环 这个循环每次从标准输入读入一个字符串 5.9 continue 语句 continue语句导致最近的循环语句的当前迭代结束 执行权被传递给条件计算部分 不 像 break 语句终止的是整个循环 continue 语句只终止当前的迭代 例如 下面的程序段读取 一个程序文本文件 每次读入一个词 它处理每个以下划线开始的词 否则 当前的循环迭 代被终止 while ( cin >> inBuf ) { if ( inBuf[0] != '_' ) continue; // 终止迭代劳 // 仍然在这里? 处理字符串 } 185 第五章 语句 continue语句只有出现在循环语句中才是合法的 5.10 goto 语句 goto语句提供了函数内部的无条件分支 它从 goto 语句跳转到同一函数内部某个位置的 一个标号语句 在当前关于良好的程序设计实践的看法中 它的用法被认为是应该抛弃的 goto语句的语法形式如下 goto label; 这里的 label 是用户定义的标识符 标号 labe 语句只能用作 goto 的目标 必须由冒 号结束 且标号语句不能紧接在结束右花括号的前面 在标号语句后面加一个空语句 是处 理这种限制的典型方法 例如 end: ; // 空语句 } goto语句不能向前跳过没有被语句块包围的声明语句 例如 下面的代码将导致编译时 刻错误 int oops_in_error() { // mumble goto end; // 错误: 跳过声明语句 int ix = 10; // ... code using ix end: ; } 正确的做法是把声明语句以及使用该声明的语句放在一个语句块中 例如 int ok_its_fixed() { // mumble goto end; { // ok: 在语句块中声明语句 int ix = 10; // ... code using ix } end: ; } 这里的原因与 switch 语句的 case 标签中的声明语句相同 是编译器为类对象插入构造函 数/析构函数调用的需要 语句块保证构造函数和析构函数都被执行或忽略 保证对象只在它 被初始化的地方才可见 然而 向后跳过一个已被初始化的对象定义不是非法的 为什么 跳过对象的初始化操 作是个程序设计错误 多次初始化一个对象尽管低效 但仍然是正确的 例如 // 向后跳过声明语句没问题 186 第五章 语句 void mumble( int max_size ) { begin: int sz = get_size(); if ( sz<= 0 ) { // 发出警告 ... goto end; } else if ( sz > max_size ) // 得到一个新的 sz 值 goto begin; { // ok: 整个语句块被跳过 int *ia = new int[ sz ]; doit( ia, sz ); // whatever that is ... delete [] ia; } end: ; } goto语句是现代程序设计语言中最过时的特性 使用 goto 语句常常使程序控制流难于理 解和修改 goto 语句的大多数用法可以用条件或循环语句来代替 如果你发现自己正在使用 goto 语句 那么建议你不要让它跨越过多的程序序列 5.11 链表示例 在第 3 章和第 4 章中 我们用一个类的设计和实现来练习 C++的类机制 并以此作为每 一章的结束 类似地 本章我们将用一个单向链表类的实现作为本章的结束 在第 6 章 我们将看到标准库提供的双向链表容器类 第一次阅读本书的读者可以略过本节 在看过 第 13 章之后再回头阅读本节内容 如果你希望继续阅读本节 那么假定你至少已经阅读了 2.3 节或 3.15 节 即至少熟悉了类机制的语法和术语 比如什么是构造函数等等 如果不是 这样 建议你先阅读那两节 或其中一节 链表是一个数据项序列 每个数据项都包含一个某种类型的值和链表下一项的地址 地 址可以为空 null 链表也可以为空 即链表中可以没有数据项 链表不可能为满 但是 当程序的空闲存储区被耗尽时 试图创建一个新链表项会失败 链表类必须支持的操作是什么 用户必须能够插入 insert 或删除 remove 以及查 找 find 一个项 用户必须能够查询链表的长度 size 显示 display 链表 以及比较 两个链表是否相等 equality 另外 还要支持翻转 reverse 链表以及连接 concatenate 两个链表 size()最简单的实现是迭代链表 返回所遍历的元素的个数 复杂一点的实现是将长度作 为一个数据成员存储起来 size()的第二个实现效率很高 它只是简单地返回相关联的成员 额外的复杂性是每次插入和删除一个元素时 都需要更新这个成员 187 第五章 语句 我们选择第二种方案 把元素个数保存在数据成员 size 中 并且在必要时更新这个数据 成员 我们的假设是用户可能会频繁查询 size() 因此 它必须足够快 把公有接口和私 有实现分离的好处之一是 如果我们的假设被证明是错误的 那么可以给出一个新的实现 而不必改变使用 size()的程序 只要保持同样的返回值类型和参数表 我们的 insert()操作有两个参数 指向一个已存在的链表元素的指针 以及一个新值 新 值被插入到这个已存在的元素之后 例如 给出链表 1 1 2 3 8 如下调用 mylist.insert( pointer_to_3, 5 ); 将把链表修改为 1 1 2 3 5 8 为了做到这一点 我们需要向用户提供一种方法来访问某个特定的数据项的地址 例如 前面例子中的元素 3 一种方法是通过 find()操作 例如 pointer_to_3 = mylist.find( 3 ); find()把待搜索的值作为它的参数 如果这个元素存在 则 find()返回指向该元素的指针 否则返回 0 我们还希望支持两种特殊情况的 insert() 在链表头和链表尾插入 这两种情况只需要用 户指定将要被插入的位 insert_front( value ); insert_end( value ); 删除操作包括以下这些 删除单个值 删除最前面的元素 删除所有的元素 remove( value ); remove_front(); remove_all(); display()操作为链表提供一个格式化的输出 包括链表的长度以及每个元素 空链表显 示如下 (0) ( ) 有 7 个元素的链表显示为 (7)( 0 1 1 2 3 5 8 ) reverse()只是翻转元素的顺序 例如 在调用 mylist.reverse(); 之后 前面的链表显示为 (7)( 8 5 3 2 1 1 0 ) 连接操作将第二个链表附加到第一个的末尾 例如 已知链表 (4)( 0 1 1 2 ) // 链表 1 188 第五章 语句 (4)( 2 3 5 8 ) // 链表 2 如下操作 list1.concat( list2 ); 将 list1 修改为 (8)( 0 1 1 2 2 3 5 8 ) 为了使 list1 成为真正的斐波那契数列 可以应用 remove() list1.remove( 2 ); 一旦定义了链表类的行为 我们下一步的工作就是实现它 我们选择把 list 链表 和 list_item 链表项 作为独立的类抽象 现在 我们把链表实现局限在 int 型的元素上 因 此 我们的类名为 ilist 和 ilist_item 我们的链表包含一个成员_at_front 它指向链表头 一个成员_at_end 它指向链表的 尾 以及成员_size 记录链表的当前长度 当一个链表对象刚刚被定义时 这三个成员 必须被初始化为 0 我们用缺省构造函数来保证这一点 class ilist_item; class ilist { public: // 缺省构造函数 ilist() : _at_front( 0 ), _at_end( 0 ), _size( 0 ) {} // ... private: ilist_item *_at_front; ilist_item *_at_end; int _size; }; 这使我们能够如下定义 ilist 对象 ilist mylist; 但是其他事情都还没有做 现在我们来增加对链表长度查询的支持 在类定义的公有部 分声明成员函数 size()后 可以如下定义这个成员函数 inline int ilist::size() { return _size; } 现在我们可以这样写 int size = mylist.size(); 我们不希望用户用一个链表初始化或赋值给另一个链表 以后我们会修改——这种修改 不会要求改动用户程序 为了防止初始化和赋值 我们把 ilist 的拷贝构造函数和拷贝赋值 操作符声明为私有成员 而且不提供它们的定义 下面是修改后的 ilist 类定义 class ilist { public: // 不再显示成员函数的定义 ilist(); 189 第五章 语句 int size(); // ... private: // 禁止用一个链表对象初始化或赋值给另一个 ilist( const ilist& ); ilist& operator=( const ilist& ); // 数据成员同前 }; 由于这个定义 下面两条语句都将导致编译时刻错误 因为 main()不能访问 ilist 类的私 有成员 int main() { ilist yourlist( mylist ); // 错误 mylist = mylist; // 错误 } 下一步工作是支持插入数据项 我们已经选择把数据项表示成一个独立的类抽象 它包 含成员_value 和_next 其中 _value 表示该项的值 _next 记录链表中下一项的地址 class ilist_item { public: // ... private: int _value; ilist_item *_next; }; ilist_item 的构造函数要求指定一个值 以及指定一个指向 ilist_item 的指针 后者是可选 项 不是必需的 如果出现第二个参数 即指向 ilist_item 的指针 的话 则新的项被插入在 该指针表示的 ilist_item 的后面 例如 给出链表 0 1 1 2 5 如下构造函数调用 ilist ( 3, pointer_to_2 ); 将链表修改为 0 1 1 2 3 5 下面是我们的实现代码 再次说明 第二个参数 item 是可选的 如果用户不提供值的 话 编译器将传递缺省值 0 缺省值是在函数声明中指定的 而不是在定义中指定 第 7 章 将对此给出完整的说明 class ilist_item { public: ilist_item( int value, ilist_item *item_to_link_to = 0 ); // ... }; inline ilist_item:: 190 第五章 语句 ilist_item( int value, ilist_item *item ) : _value( value ) { if ( !item ) _next = 0; else { _next = item->_next; item->_next = this; } } ilist 中普通版本的 insert()操作以一个待插入的值以及一个 ilist_item 指针作为参数 新的 项被插在这个指针所指的项的后边 下向是我们的第一个作品 有两个问题——你能找出来 吗 inline void ilist:: insert( ilist_item *ptr, int value ) { new ilist_item( value, ptr ); ++_size; } 第一个问题是 没有核实指针是否含有非 0 的地址 我们必须识别和处理这种情况 因 为它的出现可能会使我们的程序在运行时刻崩溃 应该如何处理呢 一种方案是通过调用 C 标准库 abort()函数来终止程序 该函数在 C 头文件 cstdlib 中 #include // ... if ( ! ptr ) abort(); 另一种方案是使用 assert()宏来终止程序 但首先要声明引起断言的条件 #include // ... assert( ptr != 0 ); 第三种方案是抛出一个异常 例如 if ( ! ptr ) throw "Panic: ilist::insert(): ptr == 0"; 一般地 我们应该尽可能地避免终止程序 终止程序实际上是对用户落井下石 而我们 或者支持部门可以分离并解决这些问题 如果我们在错误点上不能继续处理 那么一般来说 抛出异常比终止程序更好一些 异 常会把控制权传送到程序先前被执行的部分 而它们有可能能够解决这个问题 我们的实现能够识别空指针 并将其视为 把 value 插入链表头 的请求 if ( ! ptr ) insert_front( value ); 在我们的实现中 第二个缺点是过于哲理性 _size 和 size()实现对是一个尝试性设计 虽然我们相信链表长度的存储和 inline 获取方式极好地满足了用户的要求 但是 实际上我 191 第五章 语句 们可以用一个策略来代替它 该策略只有在必要的时候它才计算链表的长度 即按需计算 在按需引算长度的实现中 成员_size 被去掉了 当我们写下下面的代码时 ++_size; 我们就把 insert()的实现与当前链表类的实现紧密地耦合在一起 如果链表类的实现改变 了 则 insert()也不再正确 也必须随之改动 同样 insert_front() insert_end()以及删除操作 的实例也必须改动 在新的设计方案中 我们不再把 对链表类的实现细节的依赖性 扩散 到这多个插入和删除操作中 而是选择了将依赖性封装到一对函数中 inline void ilist::bump_up_size() { ++_size; } inline void ilist::bump_down_size() { --_size; } 因为已经将函数声明为 inline 所以我们的设计不会影响实现的效率 下面是修改后的 实现 inline void ilist:: insert( ilist_item *ptr, int value ) { if ( !ptr ) insert_front( value ); else { bump_up_size(); new ilist_item( value, ptr ); } } 显然 insert_front()和 insert_end()的实现很容易 它们都必须处理空链表的这种特例 下 面是它们的实现 inline void ilist:: insert_front( int value ) { ilist_item *ptr = new ilist_item( value ); if ( !_at_front ) _at_front = _at_end = ptr; else { ptr->next( _at_front ); _at_front = ptr; } bump_up_size(); } inline void ilist:: insert_end( int value ) { if ( !_at_end ) _at_end = _at_front = new ilist_item( value ); else _at_end = new ilist_item( value, _at_end ); bump_up_size(); 192 第五章 语句 } find()在链表中查找一个值 如果这个值存在 则 find()返回指向该值的指针 否则返回 0 下面是它的实现 ilist_item* ilist:: find( int value ) { ilist_item *ptr = _at_front; while ( ptr ) { if ( ptr->value() == value ) break; ptr = ptr->next(); } return ptr; } 我们可以按如下方式使用 find() ilist_item *ptr = mylist.find( 8 ); mylist.insert( ptr, some_value ); 或用更紧凑的用法 mylist.insert( mylist.find( 8 ), some_value ); 在练习插入操作之前 我们需要 display()函数 以便能够看到我们把程序修改得是否合 适 display()的算法十分简单 从第一个元素开始 按顺序输出每个元素直到全部输出为止 你知道为什么下面的 for 循环设计失败了吗 // 喔! 不正确 // 目的: 显示链表中除了最后一个元素之外的所有元素 for ( ilist_item *iter = _at_front; // 从链表的最前面开始 iter != _at_end; // 在链表尾终止 ++iter ) // 往前移动一项 cout << iter->value() << ' '; // 现在显示最后一个元素 cout << iter->value(); 失败的原因是 链表中的元素并不是被连续存储在内存中的 指针的算术运算 ++iter; 并没有把 iter 向前移动到指向 ilist 下一个元素的位置上 而是把 iter 加上了一个 ilist_item 对象的字节长度 我们不知道递增后的 iter 指向什么对象 也不知道循环是否会结束 为了 指向链表的下一个元素 iter 必须在每次迭代后 显式地被重置为由 ilist_item 的数据成员_next 指向的下一项 iter = iter->_next; 我们用一组 inline 访问函数封装对成员_value 和_next 的访问 下面是修订过的 ilist_item 193 第五章 语句 类的定义 class ilist_item { public: ilist_item( int value, ilist_item *item_to_link_to = 0 ); int value() { return _value; } ilist_item* next() { return _next; } void next( ilist_item *link ) { _ne xt = link; } void value( int new_value ) { _value = new_value; } private: int _value; ilist_item *_next; }; 下面是 display()的实现 它使用了前面的 ilist_item 类定义 #include class ilist { public: void display( ostream &os = cout ); // ... }; void ilist:: display( ostream &os ) { os << "\n( " << _size << " )( "; ilist_item *ptr = _at_front; while ( ptr ) { os << ptr->value() << " "; ptr = ptr->next(); } os << ")\n"; } 下面是一个小程序 它练习了目前我们定义的 ilist 类 #include #include "ilist.h" int main() { ilist mylist; for ( int ix = 0; ix< 10; ++ix ) { mylist.insert_front( ix ); mylist.insert_end( ix ); } cout << "Ok: after insert_front() and insert_end()\n"; 194 第五章 语句 mylist.display(); ilist_item *it = mylist.find( 8 ); cout << "\n" << "Searching for the value 8: found it<<" << ( it << " yes!\n" : " no!\n" ); mylist.insert( it, 1024 ); cout << "\n" << "Inserting element 1024 following the value 8\n"; mylist.display(); int elem_cnt = mylist.remove( 8 ); cout << "\n" << "Removed " << elem_cnt << " of the value 8\n"; mylist.display(); cout << "\n" << "Removed front element\n"; mylist.remove_front(); mylist.display(); cout << "\n" << "Removed all elements\n"; mylist.remove_all(); mylist.display(); } 编译并运行程序 产生如下输出 Ok: after insert_front() and insert_end() ( 20 )( 9 8 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 8 9 ) Searching for the value 8: found it<< yes! Inserting element 1024 following the value 8 ( 21 )( 9 8 1024 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 8 9 ) Removed 2 of the value 8 ( 19 )( 9 1024 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 9 ) Removed front element ( 18 )( 1024 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 9 ) Removed all elements ( 0 )( ) 如同向链表中插入数据项一样 用户也需要从链表中删除数据项 我们支持三种删除元 素的操作 void remove_front(); void remove_all(); int remove( int value ); 195 第五章 语句 下面是 remove_front()的实现代码 inline void ilist:: remove_front() { if ( _at_front ) { ilist_item *ptr = _at_front; _at_front = _at_front ->next(); bump_down_size(); delete ptr; } } remove_all()重复调用 remove_front() 直到链表为空 void ilist:: remove_all() { while ( _at_front ) remove_front(); _size = 0; _at_front = _at_end = 0; } 实现 remove()的一般做法也要利用 remove_front()来处理特例 即当一个或多个要被删除 的项位于链表头的时候 否则 我们用两个指针来迭代链表 一个指向前一个元素 另一个 指向当前元素 在迭代过程中 找到要被删除的项并删除它 然后再重新连接链表 下面是 实现代码 int ilist:: remove( int value ) { ilist_item *plist = _at_front; int elem_cnt = 0; while ( plist && plist->value() == value ) { plist = plist->next(); remove_front(); ++elem_cnt; } if ( ! plist ) return elem_cnt; ilist_item *prev = plist; plist = plist->next(); while ( plist ) 196 第五章 语句 { if ( plist->value() == value ) { prev->next( plist->next() ); delete plist; ++elem_cnt; bump_down_size(); plist = prev->next(); if ( ! plist ) { _at_end = prev; return elem_cnt; } } else { prev = plist; plist = plist->next(); } } return elem_cnt; } 下面的程序练习了这些删除操作 测试了下列情况 1 所有被删除的项位于链表尾 2 删除链表中的所有项 3 不存在要被删除的项 4 有的项位于链表头 同时有的 项位于链表的后部 #include #include "ilist.h" int main() { ilist mylist; cout << "\n-----------------------------------------------\n" << "test #1: items at end\n" << "------------------------------------------------\n"; mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.insert_front( 2 ); mylist.insert_front( 3 ); mylist.insert_front( 4 ); mylist.display(); int elem_cnt = mylist.remove( 1 ); cout << "\n" << "Removed " << elem_cnt << " of the value 1\n"; mylist.display(); mylist.remove_all(); cout << "\n-----------------------------------------------\n" << "test #2: items at front \n" << "-------------------------------------------------\n"; 197 第五章 语句 mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.display(); elem_cnt = mylist.remove( 1 ); cout << "\n" << "Removed " << elem_cnt << " of the value 1\n"; mylist.display(); mylist.remove_all(); cout << "\n-----------------------------------------------\n" << "test #3: no items present\n" << "------------------------------------------------\n"; mylist.insert_front( 0 ); mylist.insert_front( 2 ); mylist.insert_front( 4 ); mylist.display(); elem_cnt = mylist.remove( 1 ); cout << "\n" << "Removed " << elem_cnt << " of the value 1\n"; mylist.display(); mylist.remove_all(); cout << "\n----------------------------------------------\n" << "test #4: items at front and end\n" << "-----------------------------------------------\n"; mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.insert_front( 0 ); mylist.insert_front( 2 ); mylist.insert_front( 4 ); mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.insert_front( 1 ); mylist.display(); elem_cnt = mylist.remove( 1 ); cout << "\n" << "Removed " << elem_cnt << " of the value 1\n"; mylist.display(); } 编译并运行该程序 产生如下结果 ---------------------------------------------------- test #1: items at end ---------------------------------------------------- ( 6 )( 4 3 2 1 1 1 ) Removed 3 of the value 1 ( 3 )( 4 3 2 ) 198 第五章 语句 ---------------------------------------------------- test #2: items at front ---------------------------------------------------- ( 3 )( 1 1 1 ) Removed 3 of the value 1 ( 0 )( ) ---------------------------------------------------- test #3: no items present ---------------------------------------------------- ( 3 )( 4 2 0 ) Removed 0 of the value 1 ( 3 )( 4 2 0 ) ---------------------------------------------------- test #4: items at front and end ---------------------------------------------------- ( 9 )( 1 1 1 4 2 0 1 1 1 ) Removed 6 of the value 1 ( 3 )( 4 2 0 ) 我们希望提供的最后两个操作是连接操作 把一个链表附加在另一个后面 和翻转操作 翻转元素的顺序 下面是 concat()的第一个实现 它是不正确的 你能看出问题来吗 void ilist::concat( const ilist &il ) { if ( ! _at_end ) _at_front = il._at_front; else _at_end->next( il._at_front ); _at_end = il._at_end; } 问题是 现在两个 ilist 对象指向同一个序列 如果改变一个 ilist 如 insert()或 remove() 则第二个链表也将受到影响 最简单的解决方案就是拷贝每个项 修订后的 concat()使用 insert_end() void ilist:: concat( const ilist &il ) { ilist_item *ptr = il._at_front; while ( ptr ) { insert_end( ptr->value() ); ptr = ptr->next(); } } 199 第五章 语句 下面是 reverse()的实现代码 void ilist:: reverse() { ilist_item *ptr = _at_front; ilist_item *prev = 0; _at_front = _at_end; _at_end = ptr; while ( ptr != _at_front ) { ilist_item *tmp = ptr->next(); ptr->next( prev ); prev = ptr; ptr = tmp; } _at_front->next( prev ); } 下面的小程序运用了我们上面的实现 #include #include "ilist.h" int main() { ilist mylist; for ( int ix = 0; ix< 10; ++ix ) { mylist.insert_front( ix ); } mylist.display(); cout << "\n" << "reverse the list\n"; mylist.reverse(); mylist.display(); ilist mylist_too; mylist_too.insert_end( 0 ); mylist_too.insert_end( 1 ); mylist_too.insert_end( 1 ); mylist_too.insert_end( 2 ); mylist_too.insert_end( 3 ); mylist_too.insert_end( 5 ); cout << "\n" << "mylist_too:\n"; mylist_too.display(); mylist.concat( mylist_too ); cout << "\n" << "mylist after concat with mylist_too:\n"; mylist.display(); } 编译并运行该程序 产生如下输出 ( 10 )( 9 8 7 6 5 4 3 2 1 0 ) 200 第五章 语句 reverse the list ( 10 )( 0 1 2 3 4 5 6 7 8 9 ) mylist_too: ( 6 )( 0 1 1 2 3 5 ) mylist after concat with mylist_too: ( 16 )( 0 1 2 3 4 5 6 7 8 9 0 1 1 2 3 5 ) 至此我们完成了设计与实现 不但实现了我们定义中所必需的操作 而且还对它们进 行了测试以保证其正确性 缺点并不在于我们已经提供了什么 而在于我们还没有提供什 么 我们的 ilist 类最严重的缺陷是用户不能迭代链表的元素 我们实现的类太简单了 所以 不支持这样的操作 而且因为我们封装了实现细节 所以用户也没有办法直接提供这样的迭 代操作 第二个不足是 这个类不支持用一个 ilist 类对象初始化或赋值给另外一个 ilist 类对象 虽然我们是有意做出这个决定的 但是用户并会不因此而减少麻烦 让我们来依次修正这些 不足 首先从拷贝和初始化开始 为了用一个对象初始化另外一个对象 我们必须定义一个 ilist 拷贝构造函数 刚开始时 不允许这样做的原因是 对于我们的链表类来说缺省行为全是错误的 一般来说 对于任何 包含指针成员的类 缺省行为都是错误的 不给用户提供一个功能 比 提供一个不正 确的功能导致用户程序死掉 要好得多 缺省行为不正确的原因将在 14.5 节中解释 拷 贝构造函数使用 insert_end() ilist::ilist( const ilist &rhs ) { ilist_item *pt = rhs._at_front; while ( pt ) { insert_end( pt->value() ); pt = pt->next(); } } 拷贝赋值操作符必须 remove_all()现有的项 然后按第二个 ilist 对象中值的顺序将这些值 insert_end() 因为在这两种情况下 插入部分的代码相同 所以我们可以把它抽取到成员 insert_all()中 void ilist::insert_all( const ilist &rhs ) { ilist_item *pt = rhs._at_front; while ( pt ) { insert_end( pt->value() ); pt = pt->next(); } } 201 第五章 语句 然后如下实现拷贝构造函数和拷贝赋值操作符 inline ilist::ilist( const ilist &rhs ) : _at_front( 0 ), _at_end( 0 ) { insert_all( rhs ); } inline ilist& ilist::operator=( const ilist &rhs ) { if ( this != &rhs ) { remove_all(); insert_all( rhs ); } return *this; } 最后 用户必须能够迭代 ilist 链表中的单独元素 支持这种功能的一种策略是简单地提 供对_at_front 的访问 ilist_item *ilist::front() { return _at_front(); } 然后 用户就能够实现一般的循环迭代 就如同我们前面所做的那样 ilist_item *pt = mylist.front(); while ( pt ) { do_something( pt->value() ); pt = pt->next(); } 虽然这样做解决了问题 但是它并不是最好的方案 我们更希望支持一般化的关于容器 元素迭代的概念 在这一节中 我们将提供这种形式的最小支持 for ( ilist_item *iter = mylist.init_iter(); iter; iter = mylist.next_iter() ) do_something( iter->value() ); 在第 6 章和第 12 章中 我们将会看到 标准库在支持容器类型和泛型算法的时侯定义 了迭代器 iterator 类型 我们在 2.8 节中已经粗略地看到了迭代器 我们的迭代器不仅仅是一个指针 因为它不但能够记忆当前的迭代项 能够返回下一项 而且还能够识别出迭代的完成情况 init_iter()缺省地将 iterator 初始化指向_at_front 用户可 以有选择地传递一个 ilist_item 指针 于是后面的迭代工作将从该处开始 next_iter()返回链 表中的下一现 如果迭代已经完成则返问 0 为了支持迭代器 我们的 ilist 实现包含了一个 附加的 ilist_iem 指针 class ilist { public: // ... init_iter( ilist_item *it = 0 ); private: // ... ilist_item *_current; }; init_iter() looks like this: inline ilist_item* ilist::init_iter( ilist_item *it ) 202 第五章 语句 { return _current = it << it : _at_front; } next_item()将_current 向前移动到下一项 并将其返回 除非迭代已经完成 如果迭代已 经完成 则 next_item()返回 0 直到 init_iter()重置_current 为止 下面是具体实现 inline ilist_item* ilist:: next_iter() { ilist_item *next = _current << _current = _current->next() : _current; return next; } 如果_current 指向的项已经被删除 那么我们的迭代支持就会有问题 我们的解决方案 是修改 remove_front()和 remove() 使它们测试_current 是否指向被删除的项 如果是 则将 _current 向前移动指向下一项 如果被删除的项是最后一项 则指向空 如果所有的项都 被删除 则_current 指向空 下面是修改后的 remove_front() inline void ilist::remove_front() { if ( _at_front ) { ilist_item *ptr = _at_front; _at_front = _at_front ->next(); // 不希望 _current 指向被删除的项 if ( _current == ptr ) _current = _at_front; bump_down_size(); delete ptr; } } 下面是 remove()的部分修改代码 while ( plist ) { if ( plist->value() == value ) { prev->next( plist->next() ); if ( _current == plist ) _current = prev->next(); 如果有一个项被插入到_current 指向的项的前面 又会怎么样呢 在这种情况下 我们 不修改_current 为了重新同步迭代过程 用户需要调用 init_iter() 另一方面 当我们用一 个 ilist 类对象初始化或拷贝给另一个对象时 _current 不会被拷贝 而是被重置为空 下面这个小程序练习了拷贝构造函数和拷贝赋值操作符以及对迭代器的支持 203 第五章 语句 #include #include "ilist.h" int main() { ilist mylist; for ( int ix = 0; ix< 10; ++ix ) { mylist.insert_front( ix ); mylist.insert_end( ix ); } cout << "\n" << "Use of init_iter() and next_iter() " << "to iterate across each list item:\n"; ilist_item *iter; for ( iter = mylist.init_iter(); iter; iter = mylist.next_iter() ) cout << iter->value() << " "; cout << "\n" << "Use of copy constructor\n"; ilist mylist2( mylist ); mylist.remove_all(); for ( iter = mylist2.init_iter(); iter; iter = mylist2.next_iter() ) cout << iter->value() << " "; cout << "\n" << "Use of copy assignment operator\n"; mylist = mylist2; for ( iter = mylist.init_iter(); iter; iter = mylist.next_iter() ) cout << iter->value() << " "; cout << "\n"; } 编译并运行程序 产生如下输出 Use of init_iter() and next_iter() to iterate across each list item: 9 8 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 8 9 Use of copy constructor 9 8 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 8 9 Use of copy assignment operator 9 8 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 8 9 5.11.1 给出一个通用链表类 我们的 ilist 类局限在 只能拥有 int 型的元素 上 更有用的链表类应该对内置数据类 型和类 class 类型都提供支持 如何改变 ilist 类 才能使其支持更广泛的元素类型 而无 需重新编程或复制代码 类模板机制提供了一个解决方案 将在第 16 章详细讨论 204 第五章 语句 通过参数化的手段 类模板抽取出我们的类设计中与类型相关的部分——在本例中 链表中元素的底层类型 以后 当用户希望使用某种特定类型的链表时 他可以为模板参数 提供实际的类型 例如 list< string > slist; 创建了一个链表模板类 list 实例 它能够包含 string 对象 而 list< int > ilist; 创建了一个实例 它与我们原先手工编码实现的 ilist 类等价 使用类模板定义 我们可 以用一个类模板实现 支持无限数目的链表元素类型 现在让我们来看一下怎样一步一步地 实现它 重点放在 list_item 类上 类模板的定义以关键字 template 开始 后面是用尖括号括起来的参数表 类型参数由 typename 或 class 加上一个标识符构成 例如 template class list_item; 它将 list_item 类声明为只有一个类型参数的类模板 elemType 是一个任意的标识符 我 们用它来命名类型参数 下面是一个等价的 list_item 类声明 template class list_item; 关键字 typename 与 class 可以互换 typename 是标准 C++中新引入的 这种写法更利于 记忆 但是 在本书写作时 对 typename 的支持没有 class 广泛 由于这个原因 我们 使用关键字 class 当然这也是因为老的习惯很难一下子改变过来 下面是 list_item 类模板的 定义 template class list_item { public: list_item( elemType value, list_item *item = 0 ) : _value( value ) { if ( !item ) _next = 0; else { _next = item->_next; item->_next = this; } } elemType value() { return _value; } list_item* next() { return _next; } void next( list_item *link ) { _next = link; } void value( elemType new_value ) { _value = new_value; } private: elemType _value; list_item *_next; }; 205 第五章 语句 先前 list_item 类定义中出现的 int 在我们的 list_item 类模板中全部被替换成 elemType 当我们写下这样的代码时 list_item *ptr = new list_item( 3.14 ); 编译器自动将 elemType 绑定到实际类型 double 上 并创建一个能够支持 double 型元素 的 list_item 类 ilist 到 list 模板类的转换以类似的方式进行 下面是类模板定义 template class list { public: list() : _at_front( 0 ), _at_end( 0 ), _current( 0 ), _size( 0 ) {} list( const list& ); list& operator=( const list& ); ~list() { remove_all(); } void insert( list_item *ptr, elemType value ); void insert_end( elemType value ); void insert_front( elemType value ); void insert_all( const list &rhs ); int remove( elemType value ); void remove_front(); void remove_all(); list_item *find( elemType value ); list_item *next_iter(); list_item* init_iter( list_item *it ); void display( ostream &os = cout ); void concat( const list& ); void reverse(); int size() { return _size; } private: void bump_up_size() { ++_size; } void bump_down_size() { --_size; } list_item *_at_front; list_item *_at_end; list_item *_current; int _size; }; 模板类对象的使用方式与显式编码的 ilist 类对象完全相同 主要好处是我们能够用一个 类模板定义支持无限数目的链表类型 模板构成了标准 C++程序设计的基本组件 在第 6 章我们将了解标准库提供的模板容器 类类型的集合 毫不奇怪的是 它包含了我们在第 2 章和第 3 章已经了解过的类模板 list 以 及类模板 vector 206 第五章 语句 标准库 list 类的出现带来了一个矛盾 我们已经选择把我们的类称作 list 而不是 ilist 不幸的是 这与标准库链表类 list 冲突 现在我们不能在同一个程序中使用这两个类 当 然 一种方案是重新命名我们的 list 类以便消除冲突 在目前这种情况下 这个方案是可行 的 因为代码毕竟是我们自己写的 但是 在大多数情况下 这个方案并不适用 更通用的解决方案是使用 C++名字空间机制 名字空间使得库厂商可以封装全局名字 以防止名字冲突 另外 名字空间也提供了访问符号 从而允许在我们的程序中使用这些名 字 例如 C++标准库被包装在名字空间 std 中 本书第二版的代码也被放到一个唯一的名字 空间中 namespace Primer_Third_Edition { template class list_item{ ... }; template class list{ ... }; // ... } 如果用户希望练习我们的 list 类 那么他可以这样写 // 我们的 list 类头文件 #include "list.h" // 使定义对程序可见 using namespace Primer_Third_Edition; // ok: 访问我们的 list list< int > ilist; // ... 我们将在 8.5 节和 8.6 节详细讨论名字空间 练习 5.16 虽然 ilist_item 类包含了一个指针成员 但是我们并没有为它定义析构函数 原因是我们 没有分配_next 指向的对象 因此也没有责任释放它 初学者容易犯的一个错误是给出一个如 下定义的析构函数 // a bad design choice ilist_item::~ilist_item() { delete _next; } 参照 remove_all()或 remove_front() 解释为什么该析构函数的存在是个不好的设计 练习 5.17 我们的 ilist 类不支持下列语句 207 第五章 语句 void ilist::remove_end(); void ilist::remove( ilist_item* ); 你认为我们为什么没有把它们包括进去 概括出一个算法来支持这两个操作 练习 5.18 修改 find() 使其带有第二个参数 ilist_item* 如果设置了该参数 则它指明了搜索的起 始处 如果没有设置该参数 则搜索过程应当和以前一样 从链表头开始 通过提供这个 新参数 并且指定一个缺省的参数值 0 我们保留了原先的公有接口 使用前一版本 find() 定义的代码不需要修改 class ilist { public: // ... ilist_item* find( int value, ilist_item *start_at = 0 ); // ... }; 练习 5.19 用新版的 find()实现 count() 它返回链表中某个值出现的次数 写一个小程序测试你的 实现 练习 5.20 修改 insert int value 函数 返回它刚刚插入的 ilist_item 指针 练习 5.21 利用修改过的 insert()函数 实现 void ilist:: insert(ilist_item *begin, int *array_of_value, int elem_cnt ); 其中 array_of_value 指向要被插入到 ilist 中的值的数组 elem_cnt 是数组中元素的个数 begin 表明从哪里开始插入元素 例如 给出一个有下列值的 ilist (3)( 0 1 21 ) 以及如下的数组 int ia[] = { 1, 2, 3, 5, 8, 13 }; 新的插入操作可以被调用如下 ilist_item *it = mylist.find( 1 ); mylist.insert(it, ia, 6 ); 它会把 mylist 修改成如下 (9)( 0 1 1 2 3 5 8 13 21 ) 208 第五章 语句 练习 5.22 concat()和 reverse()的一个问题是它们都修改了原始的链表 但这并不总是合乎要求 请 提供一对替代操作 使它们返回一个新的 ilist 对象 ilist ilist::reverse_copy(); ilist ilist::concat_copy( const ilist &rhs ); 6 抽象容器类型 本章将对第 3 章和第 4 章的内容进行扩充和完善 我们将继续在第 3 章已经开始的 类型讨论 并给出关于 string 和 vector 类型更多的信息 以及 C++标准库的其他容 器类型 另外 我们还将展示容器类型对象所支持的操作 以继续进行在第 4 章已 经开始的 关于操作和表达式的讨论 顺序容器 sequence container 拥有由单一类型元素组成的一个有序集合 两个主要的 顺序容器是 list 和 vector 第三个顺序容器为双端队列 deque 发音为 deck 它提供了 与 vector 相同的行为 但是对于首元素的有效插入和删除提供了特殊的支持 例如 在实现 队列时 队列是一种抽象 用户每次总是获取第一个元素 deque 比 vector 更为合适 在 本书的剩余部分 每当我们描述 vector 所支持的操作时 deque 也同样支持这些操作 关联容器 associative container 支持查询一个元素是否存在 并且可以有效地获取元素 两个基本的关联容器类型是 map 映射 和 set 集合 map 是一个键/值 key/value 对 键 key 用于查询 而值 value 包含我们希望使用的数据 例如 map 可以很好地支持 电话目录 键是人名 值是相关联的电话号码 set包含一个单一键值 有效支持关于元素是否存在的查询 例如 当我们要创建一个单 词数据库 且它包含在某个文本中出现的单词时 文本查询系统对能会生成一个单词集合以 排除 the and 以及 but 等等 程序将顺次读取文本中的每个单词 检查它是否属于被排除单 词的集合 并根据查询的结果将其丢弃或者放入数据库中 map和 set 都只包含每个键的惟一出现 即每个键只允许出现一次 multimap 多映射 和 multiset 多集合 支持同一个键的多次出现 例如 我们的电话目录可能需要为单个用 户支持多个列表 一种实现方法是使用 multimap 在接下去的几节中 我们将详细描述容器类型 并通过一个小的文本查询程序的循序渐 进实现 对这些容器类型进行讨论 6.1 我们的文本查询系统 文本查询系统应该由什么构成呢 210 第六章 抽象容器类型 1 用户指定的任意文本文件 2 一个逻辑查询机制 用户可以通过它查询文本中的单词或相邻的单词序列 如果一个单词或相邻的单词序列被找到 则程序显示出该单词或单词序列的出现次数 如果用户希望的话 则包含单词或单词序列的句子也会被显示出来 例如 如果用户希望找 到所有对 Civil War 或 Civil Rights 的引用 则查询可能如下12 Civil && ( War || Rights ) 查询结果如下 Civil: 12 occurrences War: 48 occurrences Rights: 1 occurrence Civil && War: 1 occurrence Civil && Rights: 1 occurrence (8) Civility, of course, is not to be confused with Civil Rights, nor should it lead to Civil War. 这里(8)表示文本中句子的序号 我们的系统应该不会将同一个词显示多次 而且多个 句子应该以升序显示 即 句子 7 应该在句子 9 之前显示 我们的程序需要支持哪些任务呢 1 它必须允许用户指明要打开的文本文件的名字 2 它必须在内部组织文本文件 以便能够识别出每个单词在句子中出现的次数 以及在 该句子中的位置 3 它必须支持某种形式的布尔查询语言 在我们的例子中 它将支持 && 在一行中 两个单词不仅存在 而且相邻 || 在一行中 两个单词至少有一个存在 ! 在一行中该单词不存在 () 把子查询组合起来的方式 因此 我们可以写 Lincoln 来找到所有出现单词 Lincoln 的句子 或者写 ! Lincoln 来找到所有没有出现单词 Lincoln 的句子 或者写 12 注意 为了简化实现 我们要求用空格分割每个单词 包括括号和布尔操作符 所以 我们的程序不能理解 下面的查询 ( War||Rights) 和 Civil&&(War||Rights) 虽然 在实际系统中 这是不合理的 因为在现实世界中 用户的便利性总是要优先于实现上的便利性 但是 在本书中 我们的目标是介绍 C++容器类型 所以这样的假设是可以被接受的 211 第六章 抽象容器类型 ( Abe || Abraham ) && Lincoln 将挑选出那些显式地引用 Abe Lillcoln 和 Abraham Lincoln 的句子 我们将给出系统的两种实现 在本章中 我们给出一个实现 它把单词项及其相关联的 行列位置当作一个 map 来解决文本文件的检索和存储问题 为了运用这个方案 我们提供 了一个单个词的查询系统 在第 17 章中 我们将给出一个完整的咨询系统实现 它将支持如 上一段讨论的那些关系操作符 之所以要到第 17 章才说明完整的实现 是因为这个方案涉及 到面向对象 Query 类层次的用法 我们使用下列六行作为文本示例 它们摘自于 Stan 写的还没有出版的儿童故事13 经过我们的处理之后 这包括读入文本的每一行 把它们分成独立的单词 去掉标点符 号 把大写字母变成小写 提供关于后缀的最小支持 以及去掉无语义的词比如 and a 和 the 支持单词查询的文本的内部存储形式看起来如下所示 alice ((0,0)) alive ((1,10)) almost ((1,9)) ask ((5,2)) beautiful ((2,7)) bird ((2,3),(2,9)) blow ((1,3)) 13 由 Elena Driskill 插图 已获得使用许可 212 第六章 抽象容器类型 daddy ((0,8),(3,3),(5,5)) emma ((0,1)) fiery ((2,2),(2,8)) flight ((2,5)) flowing ((0,4)) hair ((0,6),(1,6)) has ((0,2)) like ((2,0)) long ((0,3)) look ((1,8)) magical ((3,0)) mean ((5,4)) more ((4,12)) red ((0,5)) same ((4,5)) say ((0,9)) she ((4,0),(5,1)) shush ((3,4)) shyly ((5,0)) such ((3,8)) tell ((2,11),(4,1),(4,10)) there ((3,5),(5,7)) thing ((3,9)) through ((1,4)) time ((4,6)) untamed ((3,2)) wanting ((4,7)) wind ((1,2)) 下面的简单查询示例使用了本章实现的程序 斜体为用户输入 please enter file name: alice_emma enter a word against which to search the text. to quit, enter a single character ==> alice alice occurs 1 time: ( line 1 ) Alice Emma has long flowing red hair. Her Daddy says enter a word against which to search the text. to quit, enter a single character ==> daddy daddy occurs 3 times: ( line 1 ) Alice Emma has long flowing red hair. Her Daddy says ( line 4 ) magical but untamed. "Daddy, shush, there is no such thing," ( line 6 ) Shyly, she asks, "I mean, Daddy, is there?" enter a word against which to search the text. to quit, enter a single character ==> phoenix Sorry. There are no entries for phoenix. 213 第六章 抽象容器类型 enter a word against which to search the text. to quit, enter a single character ==> . Ok, bye! 为了更容易地实现这个程序 我们需要详细地了解标准库的容器类型 同时重新回顾第 3 章介绍的 string 类 6.2 vector 还是 list 我们的程序必须要做的第一件事情是存储来自文本文件的未知数目的单词 这些单词将 被依次存储为 string 对象 我们的第一个问题是 应该把单词存储在顺序容器还是关联容器 中 从某种意义上讲 我们需要支持查询单词是否存在 如果存在的话 还要获取到它在文 本中相关的出现位置 因为我们需要查找并获取一个值 所以关联容器 map 是最合适的容器 类型 但是 在此之前 我们只需要简单地把输入文本存储起来以供后续处理 即去掉标点符 号 处理后缀等等 对于这个前处理过程 我们只需要顺序容器 而不是关联容器 问题 是 它应该是 vector 还是 list 如果曾经在 C 语言中或在 C++标准化之前编写过程序 那么你的第一个选择可能是 如 果在编译时元素的个数已知 则使用数组 如果元素的个数未知或者可能变化范围很大 则 使用 list 为每个对象动态分配存储区 然后再把它们按顺序链接在 list 中 但是 这样的选择规则对于顺序容器类型并不适用 vector deque 以及 list 都是动态增 长的 在这三者之中选择的准则主要是关注插入特性以及对元素的后续访问要求 vector表示一段连续的内存区域 每个元素被顺序存储在这段内存中 对 vector 的随机 访问 比如先访问元素 5 然后访问 15 然后再访问 7 等等 效率很高 因为每次访问离 vector 起始处的位移都是固定的 但是 在任意位置 而不是在 vector 末尾插人元素 则效率很低 因为它需要把待插入元素右边的每个元素都拷贝一遍 类似地 删除任意一个 而不是 vector 的最后一个元素 效率同样很低 因为待删除元素右边的每个元素都必须被复制一遍 这种 代价对于大型的 复杂的类对象来说尤其大 一个 deque 也表示一段连续的内存区域 但 是 与 vector 不同的是 它支持高效地在其首部插入和删除元素 它通过两级数组结构来实 现 一级表示实际的容器 第二级指向容器的首和尾 list表示非连续的内存区域 并通过一对指向首尾元素的指针双向链接起来 从而允许 向前和向后两个方向进行遍历 在 list 的任意位置插入和删除元素的效率都很高 指针必须 被重新赋值 但是 不需要用拷贝元素来实现移动 另一方面 它对随机访问的支持并不好 访问一个元素需要遍历中间的元素 另外 每个元素还有两个指针的额外空间开销 下面是选择顺序容器类型的一些准则 如果我们需要随机访问一个容器 则 vector 要比 list 好得多 如果我们已知要存储元素的个数 则 vector 又是一个比 list 好的选择 如果我们需要的不只是在容器两端插入和删除元素 则 list 显然要比 vector 好 214 第六章 抽象容器类型 除非我们需要在容器首部插入和删除元素 否则 vector 要比 deque 好 如果我们既需要随机访问元素 又需要随机插入和删除元素 那么又该怎么办呢 我们 需要在 随机访问的代价 和 拷贝右边或左边相邻元素的代价 之间进行折衷 一般来说 应该是由应用程序的主要操作 查找或插入 来决定容器类型的选择 为了做这个决定 我们可能需要知晓两种容器类型的性能 如果两种容器类型的性能都不能够使我们满意 则需要自己设计更复杂的数据结构 当我们不知道需要存储的元素的个数 即容器需要动态增长 并且不需要随机访问元 素 以及在首部或者中间插入元素时 我们该如何决定选择哪一个容器类型呢 在这种情况 下 list 和 vector 哪一个更有效 我们将把这个问题的答案推延到下一节 list以简单方式增长 每当一个新对象被插入到 list 中时 插入处的两个元素的前指针和 后指针被重新赋值为指向新对象 新对象的前后指针被初始化为指向这两个元素 list 只占 有其包含的元素所必需的存储区 额外的开销有两个方面 与每个值相关联的两个附加指针 以及通过指针进行的间接访问 动态 vector 的表示和额外开销更加复杂 我们将在下一节介绍它 练习 6.1 对于以下程序任务 vector deque 和 list 哪一个最合适 或者都不合适 (a) 为了生成随机的英文句子 从一个文件读入未知数目的单词 (b) 读入固定数目的单词 在输入时把它们按字母顺序插入到容器中 (c) 读入未知数目的单词 总是在后面插入一个新单词 从头删除下一个元素 (d) 从文件读入未知数目的整数 对这些整数排序 然后把它们输出到标准输出 6.3 vector 怎样自己增长 一个需要动态增长的 vector 必须分配一定的内存以便保存新的序列 按顺序拷贝旧序列 的元素以及释放旧的内存 而且 如果它的元素是类对象 那么拷贝和释放内存可能需要对 每个元素依次调用拷贝构造函数和析构函数 冈为 list 的每次增长 只是简单地链接新元素 所以看起来好像没有问题 在动态增长的支持方面 这两个容器类型之中 list 更为有效 但 实际上并不是这样 让我们来看看为什么 为了提高效率 实际上 vector 并不是随每一个元素的插入而增长自己 而是当 vector 需 要增长自身时 它实际分配的空间比当前所需的空间要多一些 也就是说 它分配了一些额 外的内存容量 或者说它预留了这些存储区 分配的额外容量的确切数目由具体实现定义 这个策略使容器的增长效率更高——因此 实际上 对于小的对象 vector 在实践中比 list 效率更高 让我们来看一看在 C++标准库的 Rogue Wave 实现版本下的一些例子 但是首先 我们要弄清楚容器的容量和长度 size 之间的区别 容量是指在容器下一次需要增长自己之前能够被加入到容器中的元素的总数 容量只与 连续存储的容器相关 例如 vector deque 或 string list 不要求容量 为了知道一个容器的 容量 我们调用它的 capacity()操作 而长度 size 是指容器当前拥有元素的个数 为了获 得容器的当前长度 我们调用它的 size()操作 例如 215 第六章 抽象容器类型 #include #include int main() { vector< int > ivec; cout << "ivec: size: " << ivec.size() << " capacity: " << ivec.capacity() << endl; for ( int ix = 0; ix < 24; ++ix ) { ivec.push_back( ix ); cout << "ivec: size: " << ivec.size() << " capacity: " << ivec.capacity() << endl; } } 在 Rogue Wave 实现版本下 在 ivec 的定义之后 它的长度和容量都是 0 但是在插入 第一个元素之后 ivec 的容量是 256 长度为 1 这意味着在 ivec 下一次需要增长之前 我 们可以向它加入 256 个元素 当我们插入第 256 个元素时 vector 以下列方式重新自我增长 它分配双倍于当前容量的存储区 把当前的值拷贝到新分配的内存中 井释放原来的内存 正如稍后我们将要看到的 同 list 相比 数据类型越大越复杂 则 vector 的效率也就越低 表 6.1 显示了各种数据类型 它们的长度 及其相关 vector 的初始容量 表格 6.1 长度 容量 以及各种数据类型 数据类型 长度 字节 初始插入后的容量 int 4 256 double 8 128 简单类 simple class #1 12 85 String 12 85 大型简单类 large simple class 8000 1 大型复杂类 large complex class 8000 1 正如你所看到的 在标准库的 Rogue Wave 实现版本下 在第一次插入时分配的元素缺 省容量接近或等于 1024 字节 然后它随每次重分配而加倍 对于大型的数据类型 容量较小 所以元素的重分配和拷贝操作成为使用 vector 的主要开销 我们这里说的复杂类是指这类提 供了一个拷贝构造函数和一个拷贝赋值操作符 表 6.2 显示了在 list 和 vector 中插入 1 千 万个上述类型所花的时间 以秒为单位 表 6.3 显示了插入 1 万个元素的时间 当元素长 度较大时就太慢了 216 第六章 抽象容器类型 表格 6.2 插入 1 千万个元素所需的时间 数据类型 list(s) vector int 1038 3.76 double 10.72 3.95 简单的类 simpleclass 12.31 5.89 string 14.42 11.80 表格 6.3 插入 1 万个元素的时间 数据类型 list(s) vector 大型简单类 large simple class 0.36 2.23 大型复杂类 large complex class 2.37 6.70 正如你所看到的 对于小的数据类型 vector 的性能要比 list 好得多 而对于大型的数 据类型则相反 list 的性能要好得多 区别是由于 vector 需要重新增长以及拷贝元素 但是 数据类型的长度不是影响容器性能的惟一标准 数据类型的复杂性也会影响到元素插入的性 能 为什么 无论是 list 还是 vector 对于已定义拷贝构造函数的类来说 插入这样的类的元素都需 要调用拷贝构造函数 拷贝构造函数用该类型的一个对象初始化该类型的另一个对象—— 最初的讨论见 2.2 节 详细讨论见 14.5 节 这正说明了在简单类和 string 的链表之间 插入代价的区别 简单类对象和大型简单类对象通过按位拷贝插入 一个对象的所有位被 拷贝到第二个对象的位中 而 string 类对象和大型复杂类对象通过调用拷贝构造函数来 插入 另外 随着每次重新分配内存 vector 必须为每个元素调用拷贝构造函数 而且 在释 放原来的内存时 它要为每个元素调用其相关类型的析构函数 关于析构函数的最初讨论见 2.2 节 vector 的动态自我增长越频繁 元素插入的开销就越大 当然 一种解决方案是当 vector 的开销变得非常大时 把 vector 转换成 list 另一种经 常使用的方案是 通过指针间接存储复杂的类对象 例如 当我们用指针存储复杂类对象时 在 vector 中插入 10000 个元素的开销从 6.70s 明显地降到 0.82s 为什么 首先 容量从 1 增 加到 256 因此重新分配的次数大大减少 其次 指向类对象的指针的拷贝和释放不需要调 用该类的拷贝构造函数和析构函数 reserve()操作允许程序员将容器的容量设置成一个显式指定的值 例如 int main() { vector< string > svec; svec.reserve( 32 ); // 把容量设置为 32 // ... } 使 svec 的长度为 0 即 0 个元素 而容量为 32 但是 根据经验发现 用一个非 1 的 217 第六章 抽象容器类型 缺省值来调整 vector 的容量看起来总会引起性能退化 例如 对于 string 和 double 型的 vector 通过 reserve()增加容量导致很差的性能 另一方面 增加大型复杂类的容量 会大大改善了 性能 如表 6.4 所示 表格 6.4 调整容量时插入 10000 元素的时间 容量 时间 s 缺省值 1 6.70 4096 5.55 8192 4.44 10000 2.22 注 非简单类 8000 字节大小 并且带有构造函数和析构函数 对于我们的文本查询系统 将使用一个 vector 来包含 string 对象 并且使用与它相关联 的缺省容量 虽然当我们插入未知数目的 string 时 vector 会动态增长 但是正如前面显示 的计时情况来看 它的性能仍然要比 list 好一些 在开始真正的实现之前 我们先来回顾一 下怎样定义一个容器对象 练习 6.2 解释容器的容量与长度 size 之间的区别 为什么在连续存储的容器中需要支持容量 的概念 而非连续的容器 比如 list 则不需要 练习 6.3 为什么用指针存储大型复杂类对象的集合效率更高 而用指针存储整型对象的集合效率 却比较低 练习 6.4 在下列情况下 list 和 vector 中哪一个是比较合适的容器类型 在每一种情况下 插入 元素的数目都是未知的 请解释你的答案 (a) 整型值 (b) 指向大型 复杂类对象的指针 (c) 大型 复杂类对象 6.4 定义一个顺序容器 为了定义一个容器对象 我们必须先包含相关联的头文件 应该是下列头文件之一 #include #include #include #include #include 218 第六章 抽象容器类型 容器对象的定义以容器类型的名字开始 后面是所包含的元素的实际类型14 例如 vector< string > svec; list< int > ilist; 定义了 svec 是一个内含 string 对象的主 vector 以及 ilist 是一个 int 型对象的空 list svec 和 ilist 都是空的 为了确认这一点 我们可以调用 empty()操作符 例如 if ( svec.empty() != true ) ; // 喔, 有错误了 插入元素最简单的方法是 push_back() 它将元素插入在容器的尾部 例如 string text_word; while ( cin >> text_word ) svec.push_back( text_word ); 每次从标准输入读取一个字符串放到 text_word 中 然后 push_back()冉将 text_word 字 符 串的拷贝插入到 svec 中 list 和 deque 容器也支持 push_front() 它把新元素插入在链表的前 端 例如 假设我们有一个 int 型的内置数组如下 int ia[ 4 ] = { 0, 1, 2, 3 }; 用 push_back() for ( int ix = 0; ix < 4; ++ix ) ilist.push_back( ia[ ix ] ); 创建序列 0 1 2 3 然而 如果使用 push_front() for ( int ix = 0; ix < 4; ++ix ) ilist.push_front( ia[ ix ] ); 则在 ilist 中创建序列 3 2 1 015 另外 我们或许希望为容器指定一个显式的长度 长度可以是常量 也可以是非常量表 达式 #include #include #include extern int get_word_count( string file_name ); const int list_size = 64; list< int > ilist( list_size ); 14 如果一个 C++编译器不支持缺省模板参数 那么它要求第二个实参指定分配器 allocator 在这样的编译器 实现下 上述两个定义被声明如下 list< int > ilist( list_size, - 1 ); vector< string > svec( 24, "pooh" ); allocator 类封装了分配和删除动态内存的抽象过程 它也是标准库预定义的类 实际上它使用了 new 和 delete 操作符 使用这样的分配器类有两个目的 通过把容器与内存分配策略的细节分开 这可以简化容器类的实 现 其次 程序员有可能实现或者指定其他的内存分配策略 比如使用共亨内存 15 如果容器的主要行为是在前端插入元素 则 deque 比 vector 的效率高 所以我们应该优先选择 deque 219 第六章 抽象容器类型 vector< string > svec(get_word_count(string("Chimera"))); 容器中的每个元素都被初始化为 与该类型相关联的缺省值 对于整数 将用缺省值 0 初值化所有元素 对于 string 类 每个元素都将用 string 的缺省构造函数初始化 除了用相关联的初始值来初始化每个元素外 我们还可以指定一个值 并用它来初始化 每个元素 例如 list< int > ilist( list_size, - 1 ); vector< string > svec( 24, "pooh" ); 除了给出初始长度外 我们还可以通过 resize()操作重新设置容器的长度 例如 当我们 写下面的代码时 svec.resize( 2 * svec.size() ); 我们将 svec 的长度加了一倍 每个新元素都被初始化为 与元素底层类型相关联的缺 省值 如果我们希望把每个新元素初始化为某个其他值 则可以把该值指定为第二个参 数 // 将新元素初始化为 piglet svec.resize( 2 * svec.size(), "piglet" ); 那么 svec 的原始定义的容量是多少 它的初始长度是 24 个元素 它的初始容量可能 是多少 对——svec 的容量也是 24 一般地 vector 的最小容量是它的当前长度 当 vector 的长度加倍时 容量一般也加倍 我们也可以用一个现有的容器对象初始化一个新的容器对象 例如 vector< string > svec2( svec ); list< int > ilist2( ilist ); 每个容器支持一组关系操作符 我们可以用来比较两个容器 这些关系操作符分别是 等于 不等于 小于 大于 小于等于 以及大于等于 容器的比较是指两个容器的元素之 间成对进行比较 如果所有元素相等而且两个容器含有相同数目的元素 则两个容器相等 否则 它们不相等 第一个不相等元素的比较决定了两个容器的小于或大于关系 例如 下 面是一个程序的输出 它比较了五个 vector ivec1: 1 3 5 7 9 12 ivec2: 0 1 1 2 3 5 8 13 ivec3: 1 3 9 ivec4: 1 3 5 7 ivec5: 2 4 // 第一个不相等元素: 1, 0 // ivec1 大于 ivec2 ivec1 < ivec2 // flase ivec2 < ivec1 // true // 第一个不相等元素: 5, 9 ivec1 < ivec3 // true // 所有元素相等, 但是, ivec4 的元素少 // 所以 ivec4 小于 ivec1 ivec1 < ivec4 // false 220 第六章 抽象容器类型 // 第一个不相等元素: 1, 2 ivec1 < ivec5 // true ivec1 == ivec1 // true ivec1 == ivec4 // false ivec1 != ivec4 // true ivec1 > ivec2 // true ivec3 > ivec1 // true ivec5 > ivec2 // true 我们能够定义的容器的类型有三个限制 实际上 它们只适用于用户定义的类类型 元素类型必须支持等于操作符 元素类型必须支持小于操作符 前面讨论的所有关系操作符都用这两个操作符来实 现 元素类型必须支持一个缺省值 对于类类型 即指缺省构造函数 所有预定义数据类型 包括指针 都满足这些限制 C++标准库给出的所有类类型也一 样 练习 6.5 说明下列代码所做的事情 #include #include #include int main() { vector svec; svec.reserve( 1024 ); string text_word; while ( cin >> text_word ) svec.push_back( text_word ); svec.resize( svec.size()+svec.size()/2 ); // ... } 练习 6.6 容器的容量可以小于它的长度吗 容量能等于它期望的长度吗 初始化时相等吗 在一 个元素被插入之后呢 为什么 练习 6.7 在练习 6.5 中 如果程序读入 256 个单词 在它被重新设置长度后可能的容量是多少 如果读入 512 个呢 1000 个 1048 个单词呢 221 第六章 抽象容器类型 练习 6.8 已知如下类定义 请指出哪些类不能用来定义 vector (a) class cl1 { (b) class cl2 { public: public: cl1( int=0 ); cl2( int=0 ); bool operator==(); bool operator!=(); bool operator!=(); bool operator<=(); bool operator<=(); // ... bool operator<(); }; // ... }; (c) class cl3 { (d) class cl4 { public: public: int ival; cl4( int, int=0 ); }; bool operator==(); bool operator==(); // ... }; 6.5 迭代器 迭代器 iterator 提供了一种一般化的方法 对顺序或关联容器类型中的每个元素进行 连续访问 例如 假设 iter 为任意容器类型的一个 iterator 则 ++iter; 向前移动迭代器 使其指向容器的下一个元素 而 *iter; 返回 iterator 指向元素的值 每种容器类型都提供一个 begin()和一个 end()成员函数 begin()返回一个 iterator 它指向容器的第一个元素 end()返回一个 iterator 它指向容器的末元素的下一个位置 为了迭代任意容器类型的元素 我们可以这样写 for ( iter = container.begin(); iter != container.end(); ++iter ) do_something_with_element( *iter ); 由于模板和嵌套类语法的原因 iterator 的定义看起来有点吓人 例如 下面是一对 iterator 的定义 它们指向一个内含 string 元素的 vector // vector vec; vector::iterator iter = vec.begin(); vector::iterator iter_end = vec.end(); iterator是 vector 类中定义的 typedef 以下语法 vector::iterator 222 第六章 抽象容器类型 引用了 vector 类中内嵌的 iterator typedef 并且该 vector 类包含 string 类型的元素 为了把每个 string 元素打印到标准输出上 我们可以这样写 for( ; iter != iter_end; ++iter ) cout << *iter << '\n'; 当然 这里*iter 的运算结果就是实际的 string 对象 除了 iterator 类型 每个容器还定义了一个 const iterator 类型 后者对于遍历 const 容器 是必需的 const iterator 允许以只读方式访问容器的底层元素 例如 #include void even_odd( const vector *pvec, vector *pvec_even, vector *pvec_odd ) { // 必须声明一个 const_iterator, 才能够遍历 pvec vector::const_iterator c_iter = pvec->begin(); vector::const_iterator c_iter_end = pvec->end(); for ( ; c_iter != c_iter_end; ++c_iter ) if ( *c_iter % 2 ) pvec_odd->push_back( *c_iter ); else pvec_even->push_back( *c_iter ); } 最后 如果我们希望查看这些元素的某个子集 该怎么办呢 如每隔一个元素或每隔三 个元素 或者从中间开始逐个访问元素 我们可以用标量算术运算使 iterator 从当前位置偏移 到某个位置上 例如 vector::iterator iter = vec.begin()+vec.size()/2; 将 iter 指向 vec 的中间元素 而 iter += 2; 将 iter 向前移动两个元素 iterator算术论算只适用于 vector 或 deque 而不适用于 list 因为 list 的元素在内存中不 是连续存储的 例如 ilist.begin() + 2; 是不正确的 因为在 list 中向前移动两个元素需要沿着内部 next 指针走两次 对于 vector 或 deque 前进两个元素需要把当前的地址值加上两个元素的长度 3.3 节给出了关于指针算 术运算的讨论 容器对象也可以用 由一对 iterator 标记的起始元素和未元素后一位置之间的拷贝 来初 始化 例如 假设我们有 #include #include #include int main() { vector svec; 223 第六章 抽象容器类型 string intext; while ( cin >> intext ) svec.push_back( intext ); // process svec ... } 我们可以定义一个新的 vector 来拷贝 svec 的全部或部分元素 int main() { vector svec; // ... // 用 svec 的全部元素初始化 svec2 vector svec2( svec.begin(), svec.end() ); // 用 svec 的前半部分初始化 svec3 vector::iterator it = svec.begin() + svec.size()/2; vector svec3( svec.begin(), it ); // 处理 vectors ... } 用特定的 istream_iterator 类型 12.4.3 节详细讨论 我们可以更直接地向 svec 插入文 本元素 #include #include #include < iterator > int main() { // 将输入流 iterator 绑定到标准输入上 istream_iterator infile( cin ); // 标记流结束位置的输入流 iterator istream_iterator eos; // 利用通过 cin 输入的值初始化 svec vector svec( infile, eos ); // 处理 svec } 除了一对 iterator 之外 两个指向内置数组的指针也可以被用作元素范围标记器 range marker 例如 假设我们有下列 string 对象的数组 #include string words[4] = { "stately", "plump", "buck", "mulligan" }; 我们可以通过传递数组 words 的首元素指针和末元素后一位置的指针来初始化 string 224 第六章 抽象容器类型 vector vector< string > vwords( words, words+4 ); 第二个指针被用作终止条件 它指向的对象 通常指向容器或者数组中最后一个元素后 面的位置上 不包含在要被拷贝或遍历的元素之中 类似地 我们可以按如下方式初始化一个内含 int 型元素的 list int ia[6] = { 0, 1, 2, 3, 4, 5 }; list< int > ilist( ia, ia+6 ); 在 12.4 节中 我们将进一步介绍 iterator 现在 我们所介绍的已经足够我们在文本查询 系统中使用它们了 但是 在回到文本查询系统的实现之前 我们需要复习一下容器类型支 持的一些其他操作 练习 6.9 下列 iterator 的用法哪些是错误的 const vector< int > ivec; vector< string > svec; list< int > ilist; (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 ) // ... 练习 6.10 下列 iterator 的用法哪些是错误的 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) list ilist2( ilist.begin(), ilist.begin()+2 ); (d) vector ivec( &ia[0], ia+8 ); (e) list slist( sa+6, sa ); (f) vector svec2( sa, sa+6 ); 6.6 顺序容器操作 push_back()方法给出了 在顺序容器尾部插入单个元素 的简短表示 但是 如果我 们希望在容器的其他位置插入元素 该怎么办呢 或者 如果我们希望在容器的尾部或某 个其他位置插入一个元素序列 该怎么办呢 在这些情况下 我们将使用一组更通用的插 225 第六章 抽象容器类型 入方法 例如 为了在容器的头部插入元素 我们将这样做 vector< string > svec; list< string > slist; string spouse( "Beth" ); slist.insert( slist.begin(), spouse ); svec.insert( svec.begin(), spouse ); 这里 insert()的第一个参数是一个位置 指向容器中某个位置的 iterator 第二个参数 是将要被插入的值 这个值被插入到由 iterator 指向的位置的前面 更为随机的插入操作可以 如下实现 string son( "Danny" ); list::iterator iter; iter = find( slist.begin(), slist.end(), son ); slist.insert( iter, spouse ); 这里 find()返回被查找元素在容器中的位置 或者返回容器的 end() iterator 表明这次 查询失败 我们将在下一节结束时回头介绍 find() 正如你所猜到的 push_back()是下列 调用的简短表示 // 等价于: slist.push_back( value ); slist.insert( slist.end(), value ); insert()方法的第二种形式支持 在某个位置插入指定数量的元素 例如 如果希望在 vector 的开始处插入 10 个 Anna 我们可以这样做 vector svec; ... string anna( "Anna" ); svec.insert( svec.begin(), 10, anna ); insert()方法的最后一种形式支持 向容器插入一段范围内的元素 例如 给出下列 string 数组 string sarray[4] = { "quasi", "simba", "frollo", "scar" }; 我们可以向字符串 vector 中插入数组中的全部或部分元素 svec.insert( svec.begin(), sarray, sarray+4 ); svec.insert( svec.begin() + svec.size()/2, sarray+2, sarray+4); 另外 我们还可以通过一对 iterator 来标记出待插入值的范围 可以是另一个 string 元素 的 vector // 插入 svec 中含有的元素 // 从 svec_two 的中间开始 svec_two.insert( svec_two.begin() + svec_two.size()/2, svec.begin(), svec.end() ); 226 第六章 抽象容器类型 或者 更一般的情况下 也可以是任意一种 string 对象的容器16 list< string > slist; // ... // 把 svec 中含有的元素插入到 // slist 中 stringVal 的前面 list< string >::iterator iter = find( slist.begin(), slist.end(), stringVal ); slist.insert( iter, svec.begin(), svec.end() ); 6.6.1 删除操作 删除容器内元素的一般形式是一对 erase()方法 一个删除单个元素 另一个删除由一对 iterator 标记的一段范围内的元素 删除容器末元素的简短方法由 pop_back()方法支持 例如 为了删除容器中一个指定的元素 我们可以简单地调用 erase() 用一个 iterator 表示它的位置 在下列代码段中 我们用 find()泛型算法获得待删除元素的 iterator 如果 list 中存在这样的元素 则把它的位置传递给 erase() string searchValue( "Quasimodo" ); list< string >::iterator iter = find( slist.begin(), slist.end(), searchValue ); if ( iter != slist.end() ) slist.erase( iter ); 要删除容器中的所有元素 或者由 iterator 对标记的子集 我们可以这样做 // 删除容器中的所有元素 slist.erase( slist.begin(), slist.end() ); // 删除由 iterator 标记的一段范围内的元素 list< string >::iterator first, last; first = find( slist.begin(), slist.end(), val1 ); last = find( slist.begin(), slist.end(), val2 ); // ... 检查 first 和 last 的有效性 slist.erase( first, last ); 最后 如同在容器尾部插入一个元素的 push_back()方法相仿 pop_back()方法删除容器 的末元素——它不返回元素 只是简单地删除它 例如 vector< string >::iterator iter = buffer.begin(); for ( ; iter != buffer.end(); iter++ ) { slist.push_back( *iter ); if ( ! do_something( slist )) slist.pop_back(); } 16 最后一种形式要求编译器支持模板成员函数 如果你的编译器还不支持标准 C++的这种特性 那么两种容器 的类型必须相同 比如内含相同类型元素的两个 vector 或 list 227 第六章 抽象容器类型 6.6.2 赋值和对换 当我们把一个容器类型赋值给另一个时 会发生什么事情 赋值操作符使用针对容器元 素类型的赋值操作符 把右边容器对象中的元素依次拷贝到左边的容器对象中 如果两个容 器的长度不相等 又会怎么样呢 例如 // slist1 含有 10 个元素 // slist2 含有 24 个元素 // 赋值之后都含有 24 个元素 slist1 = slist2; 赋值的目标 在本例中为 slist1 它现在拥有与被拷贝容器 本例中为 slist2 相同的元 素数目 slist1 中的前 10 个元素被删除 在 slist1 的情况下 string 的析构函数被应用在这 10 个 string 元素上 swap()可以被看作是赋值操作符的互补操作 当我们写 slist1.swap( slist2 ); 时 slist1 现在含有 24 个 string 元素 是用 string 赋值操作符拷贝的 就如同我们写 slist1 = slist2; 一样 两者的区别在于 slist2 现在含有 slist1 中原来含有的 10 个元素的拷贝 如果两个容器 的长度不同 则容器的长度就被重新设置 且等于被拷贝容器的长度 6.6.3 泛型算法 上节描述的操作都是 vector 和 deque 容器提供的基本操作 毫无疑问 那只是一个相当 小的接口 它省略了像 find() sort()和 merge()等基本操作 从概念上讲 我们的思想是把所 有容器类型的公共操作抽取出来 形成一个通用算法集合 它能够被应用到全部容器类型以 及内置数组类型上 这组通用算法被称作泛型算法 泛型算法将在 12 章和附录中详细讨论 泛型算法通过一个 iterator 对 被绑定到一个特殊的容器上 例如 下面的代码显示了我们怎 样在一个 list vector 以及不同类型的数组上调用 find()泛型算法 #include #include int ia[ 6 ] = { 0, 1, 2, 3, 4, 5 }; vector svec; list dlist; // the associated header file #include vector::iterator viter; list::iterator liter; int *pia; // 如果找到, find()返回指向该元素的 iterator // 对于数组, 返回指针 228 第六章 抽象容器类型 pia = find( &ia[0], &ia[6], some_int_value ); liter = find( dlist.begin(), dlist.end(), some_double_value ); viter = find( svec.begin(), svec.end(), some_string_value ); 因为 list 容器类型不支持随机访问其元素 所以它提供了额外的操作 如 merge()和 sort() 这些都将在 12.6 节中讨论 现在 我们回头来看我们的文本查询系统 练习 6.11 请写一个程序 使它接受下列定义 int ia[] = { 1, 5, 34 }; int ia2[] = { 1, 2, 3 }; int ia3[] = { 6, 13, 21, 29, 38, 55, 67, 89 }; vector ivec; 用各种插入操作 以及 ia2 和 ia3 适当的值修改 ivec 使它拥有序列 { 0, 1, 1, 2, 3, 5, 8, 13, 21, 55, 89 } 练习 6.12 请写一个程序 使它接受下列定义 int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 55, 89 }; list ilist( ia, ia+11 ); 用单个 iterator 形式的 erase()删除 ilist 中所有奇数位置的元素 6.7 存储文本行 我们的第一个任务是读入用户需要查询的文本文件 需要获得下列信息 每个单词 当 然 还有每个单词的位置——即 它在哪一行 以及在该行的位置 而且 为了显示出与一 个查询相匹配的文本行 我们必须按行号保留文本 怎样获得文本的每一行呢 标准库支持 getline()函数 声明如下 istream& getline( istream &is, string str, char delimiter ); getline()读取 istream 对象 向 string 对象插入字符 包括空格 直到遇到分割符 文件 结束 或者被读入的字符序列等于 string 对象的 max_size()值 在该点处读入操作失败 在每次调用 getline()之后 我们都会将 str 插入到代表文本的字符串 vector 中 下面是一 般化的实现17 我们已经将它提取到一个函数中 命名为 retrieve_text() 为了增加被收集到 的信息 我们定义了一对值来存储最长行的行数和长度 完整的程序列表在 6.14 节 17 它是在不支持缺省模板参数值的编译器下被编译的 所以我们必须显式提供一个分配器 allocator vector< string, allocator > *lines_of_text; 在一个完全支持标准 C++的编译器下面 我们只需要指定元素的类型 vector< string > *lines_of_text; 229 第六章 抽象容器类型 // 返回值是指向 string vector 的指针 vector* retrieve_text() { string file_name; cout << "please enter file name: "; cin >> file_name; // 打开文本文件以便输入 ... ifstream infile( file_name.c_str(), ios::in ); if ( ! infile ) { cerr << "oops! unable to open file " << file_name << " -- bailing out!\n"; exit( -1 ); } else cout << '\n'; vector *lines_of_text = new vector; string textline; typedef pair stats; stats maxline; int linenum = 0; while ( getline( infile, textline, '\n' )) { cout << "line read: " << textline << '\n'; if ( maxline.first << textline.size() ) { maxline.first = textline.size(); maxline.second = linenum; } lines_of_text->push_back( textline ); linenum++; } return lines_of_text; } 程序的输出如下 不幸的是 由于页面尺寸限制的原因 我们已经手工做了整理以便于 阅读 please enter file name: alice_emma line read: Alice Emma has long flowing red hair. Her Daddy says line read: when the wind blows through her hair, it looks almost alive, line read: like a fiery bird in flight. A beautiful fiery bird, he tells her, line read: magical but untamed. "Daddy, shush, there is no such thing," line read: she tells him, at the same time wanting him to tell her more. line read: Shyly, she asks, "I mean, Daddy, is there?" 230 第六章 抽象容器类型 number of lines: 6 maximum length: 66 longest line: like a fiery bird in flight. A beautiful fiery bird, he tells her, 由于每个文本行都是作为 string 被存储的 所以我们需要把每行拆成独立的单词 对于 每个单词 我们将首先去掉标点符号 例如 考虑下面来自 Finnegans Wake 的 Anna Livia Plurrabelle 片断的行 "For every tale there's a telling, and that's the he and she of it." 它产生下列单独的 string 这些 string 可能带有内嵌的标点符号 "For there's telling, that's it." 这些 string 需要被变成 For there telling that it 有人可能会说 there's 应该变成 there is 但是实际上我们打算走向另一个方向 我们将丢弃没有语义的单词 如 is that and it 以及 the 等等 因此 对摘自 Finnegans Wake 的行 我们用来查询的活动单词只有以下 两个会被输入 tale telling 我们将用一个专门排除单词的 set 来实现这一点 这将在后面关于 set 容器类型的章节 中详细讨论 除了去掉标点符号外 我们还需要去掉大写字母 并提供对后缀的最小处理 大写字母 在下列文本行中成为一个问题 Home is where the heart is. A home is where they have to let you in. 显然 关于 home 的查询需要找到两次 对于后缀的处理是要解决一个更为复杂的识别问题 例如 识别 dog 和 dogs 表示相同的 名词 love loves 以及 loving 表示同一动词 231 第六章 抽象容器类型 下一节的目的是重新回顾标准库 string 类 练习 string 处理操作的扩展集合 沿着这条 路 我们将进一步开发我们的文本查询系统 6.8 找到一个子串 我们的第一个任务是将表示文本行的字符串分解成独立的单词 我们将通过找到内嵌的 空格来达到这个目的 例如 给出 Alice Emma has long flowing red hair. 通过标记出其中的六个空格 我们能识别出七个子字符串 它们表示了文本行中实际的 单词 为了做到这一点 我们使用 string 类支持的多个 find()函数之一 string类提供了一套查找函数 都以 find 的各种变化形式命名 find()是最简单的实例 给出一个字符串 它返回匹配子串的第一个字符的索引位置 或者返回一个特定的值 string::npos 表明没有匹配 例如 #include #include int main() { string name( "AnnaBelle" ); int pos = name.find( "Anna" ); if ( pos == string::npos ) cout << "Anna not found!\n"; else cout << "Anna found at pos: " << pos << endl; } 虽然返回的索引类型差不多总是 int 类型 但是 更严格的 可移植的正确声明应该使 用以下形式 string::size_type 来保存从 find()返回的索引值 例如 string::size_type pos = name.find( "Anna" ); find()并没有提供我们所需要的确切功能 然而 find_first_of()提供了这样的功能 find_first_of()查找与被搜索字符串中任意一个字符相匹配的第一次出现 并返回它的索引位 置 例如 下列代码找到字符串中的第一个数字 #include #include int main() { string numerics( "0123456789" ); string name( "r2d2" ); string::size_type pos = name.find_first_of( numerics ); cout << "found numeric at index: " << pos << "\telement is " 232 第六章 抽象容器类型 << name[pos] << endl; } 在这个例子中 pos 被设置为 1 记住 字符串的元素从 0 开始索引 但是 这仍然不是我们所需要的 我们需要顺序找到所有的出现 而不是第一个出现 我们可以通过给出第二个参数来实现 这个参数指明了字符串中起始查找位置的索引 下面 重写了对 rad2 的查找 但它还不是完全正确的 你能看出有什么问题吗 #include #include int main() { string numerics( "0123456789" ); string name( "r2d2" ); string::size_type pos = 0; // 这里存在错误! while (( pos = name.find_first_of( numerics, pos )) != string::npos ) cout << "found numeric at index: " << pos << "\telement is " << name[pos] << endl; } 循环开始时 pos 被初始化为 0 即从 0 开始查找字符串 在索引位置 1 上出现一次匹 配 pos 被赋值为该值 因为它不等于 npos 所以继续执行循环体 第二个 find_first_of() 执行时 pos 是 1 喔 索引位置 1 被匹配第二次 第三次 第四次 我们已经陷入了 无限循环 需要在循环的下一次迭代开始之前 将 pos 递增 1 使其指向被找到元素的后一 位置 // ok: 被改正之后的循环迭代 while (( pos = name.find_first_of( numerics, pos )) != string::npos ) { cout << "found numeric at index: " << pos << "\telement is " << name[pos] << endl; // 移到被找到元素的后一位置 ++pos; } 为了在文本行中找到内嵌的空格 我们只需把 numerics 换成一个含有可能遇到的空格字 符的字符串 但是 如果我们确定只有一个空格字符被用到 那么就可以显式地提供这个字 符 例如 // 程序片断 while (( pos = textline.find_first_of( ' ', pos )) != string::npos ) // ... 为了标记出每个单词的长度 我们引入了第二个位置对象 如下所示 233 第六章 抽象容器类型 // 程序片断 // pos: 单词后一位置的索引 // prev_pos: 单词开始的索引 string::size_type pos = 0, prev_pos = 0; while (( pos = textline.find_first_of( ' ', pos )) != string::npos ) { // 对 string 进行一些操作 // 调整位置标记器 prev_pos = ++pos; } 对于循环的每次迭代 prev_pos 索引单词的开始 pos 持有单词末尾的下一个位置 空 格的位置 然后 每个被标识的单词的长度为 pos - prev_pos; // 标识单词长度 现在我们已经标识出单词 接着就需要拷贝它了 把它放在一个字符串 vector 中 拷贝 单词的一种策略是从 prev_pos 到 pos 减 1 循环遍历 textline 顺次拷贝每个字符 抽取出由这 两个索引标记的子字符串 但是 我们不需要自己去做 由 substr()字符串操作来完成就可以 了 // 程序片断 vector words; while (( pos = textline.find_first_of( ' ', pos )) != string::npos ) { words.push_back( textline.substr( prev_pos, pos-prev_pos)); prev_pos = ++pos; } substr()操作生成现有 string 对象的子串的一个拷贝 它的第一个参数指明开始的位置 第二个可选的参数指明子串的长度 如果省略第二个参数 将拷贝字符串的余下部分 我们的实现有个错误 在插入每一行的最后一个单词时 它就会失败 你知道为什么吗 考虑下面的文本行 seaspawn and seawrack 前两个单词由空格标记 这两个空格的位置都由 find_first_of()返回 但是 第三次调用 并没有找到空格符 它将 pos 置为 string::npos 结束了循环 那么 最后一个单词的处理必 须跟在循环结束之后 下面是完整的实现 我们已将其放入一个被称为 separate_words()的函数中 除了把每个 单词存储在字符串 vector 中之外 我们还计算了每个单词的行列位置 在以后对基于位置的 文本查询的支持中我们将需要这些信息 typedef pair location; 234 第六章 抽象容器类型 typedef vector loc; typedef vector text; typedef pair text_loc; text_loc* separate_words( const vector *text_file ) { // words: 包含独立单词的集合 // locations: 包含相关的行/列信息 vector *words = new vector; vector *locations = new vector; short line_pos = 0; // current line number // iterate through each line of text for ( ; line_pos < text_file->size(); ++line_pos ) { // textline: 待处理的当前文本行 // word_pos: 文本行中的当前列位置 short word_pos = 0; string textline = (*text_file)[ line_pos ]; string::size_type pos = 0, prev_pos = 0; while (( pos = textline.find_first_of( ' ', pos )) != string::npos ) { // 存储当前单词子串的拷贝 words->push_back( textline.substr( prev_pos, pos - prev_pos )); // 将行/列信息存储为 pair locations ->push_back( make_pair( line_pos, word_pos )); // 为下一次迭代修改位置信息 ++word_pos; prev_pos = ++pos; } // 现在处理最后一个单词 words->push_back( textline.substr( prev_pos, pos - prev_pos )); locations->push_back( make_pair( line_pos, word_pos )); } return new text_loc( words, locations ); } 到现在为止 我们的程序的控制流如下 int main() { vector *text_fo;e = retroeve_text); 235 第六章 抽象容器类型 text_loc *text_locations = separate_words( text_file ); // ... } separate_words()在 text_file 上的部分执行情况如下 eol: 52 pos: 5 line: 0 word: 0 substring: Alice eol: 52 pos: 10 line: 0 word: 1 substring: Emma eol: 52 pos: 14 line: 0 word: 2 substring: has eol: 52 pos: 19 line: 0 word: 3 substring: long eol: 52 pos: 27 line: 0 word: 4 substring: flowing eol: 52 pos: 31 line: 0 word: 5 substring: red eol: 52 pos: 37 line: 0 word: 6 substring: hair. eol: 52 pos: 41 line: 0 word: 7 substring: Her eol: 52 pos: 47 line: 0 word: 8 substring: Daddy last word on line substring: says ... textline: magical but untamed. "Daddy, shush, there is no such thing," eol: 60 pos: 7 line: 3 word: 0 substring: magical eol: 60 pos: 11 line: 3 word: 1 substring: but eol: 60 pos: 20 line: 3 word: 2 substring: untamed. eol: 60 pos: 28 line: 3 word: 3 substring: "Daddy, eol: 60 pos: 35 line: 3 word: 4 substring: shush, eol: 60 pos: 41 line: 3 word: 5 substring: there eol: 60 pos: 44 line: 3 word: 6 substring: is eol: 60 pos: 47 line: 3 word: 7 substring: no eol: 60 pos: 52 line: 3 word: 8 substring: such last word on line substring: thing," ... textline: Shyly, she asks, "I mean, Daddy, is there?" eol: 43 pos: 6 line: 5 word: 0 substring: Shyly, eol: 43 pos: 10 line: 5 word: 1 substring: she eol: 43 pos: 16 line: 5 word: 2 substring: asks, eol: 43 pos: 19 line: 5 word: 3 substring: "I eol: 43 pos: 25 line: 5 word: 4 substring: mean, eol: 43 pos: 32 line: 5 word: 5 substring: Daddy, eol: 43 pos: 35 line: 5 word: 6 substring: is last word on line substring: there?" 在加入我们的文本查询函数集合之前 先简要地概括一下 string 类支持的其他查找函数 除了 find()和 find_first_of()外 string 类还支持其他几个查找操作 rfind() 查找最后 即最 右 的指定子串的出现 例如 string river( "Mississippi" ); string::size_type first_pos = river.find( "is" ); string::size_type last_pos = river.rfind( "is" ); find()返回索引值 1 表明第一个 is 的开始 而 rfind()返回索引值 4 表明 is 的最 后一个出现的开始 236 第六章 抽象容器类型 find_first_not_of()查找第一个不与要搜索字符串的任意字符相匹配的字符 例如 为找 到第一个非数字字符 可以写 string elems( "0123456789" ); string dept_code( "03714p3" ); // returns index to the character 'p' string::size_type pos = dept_code.find_first_not_of(elems); find_last_of()查找字符串中的 与搜索字符串任意元素相匹配 的最后一个字符 find_last_not_of()查找字符串中的 与搜索字符串任意字符全不匹配 的最后一个字符 这些 操作都有一个可选的第二参数来指明起始的查找位置 练习 6.13 写一个程序 已知下列字符串 "ab2c3d7R4E6" 先用 find_first_of() 然后再用 find_first_not_of()查找每个数字字符 最后查找每个英文 字母 练习 6.14 写一个程序 已知字符串 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; 统计句子中单词的个数并找出最大的和最小的单词 如果不只有一个最大或最小单词 则把它们全部列出来 指定位置 6.9 处理标点符号 我们已经把每个文本行分解成独立的单词 现在需要把单词中可能附加的标点符号去掉 例如 下列文本行 magical but untamed. "Daddy, shush, there is no such thing," 被分解成 magical but untamed. "Daddy, shush, there is no 237 第六章 抽象容器类型 such thing," 怎样去掉不想要的标点呢 首先 我们将定义一个字符串 它包含我们希望去掉的所有 标点 string filt_elems( "\",.;:!?)(\\/" ); \"和\\序列表示 第一个序列中的引号和第二个序列中的第二个反斜杠被视为该字符串 中的文字元素 而不是字符串的结尾或下一行的续行符号 接下来 我们将用 find_first_of()操作在我们的字符串里找到每个匹配元素 while (( pos = word.find_first_of( filt_elems, pos )) != string::npos ) 最后 我们需要用 erase()去掉字符串中的标点 word.erase(pos, 1); 这个版本的 erase()操作的第一个参数表示字符串中要被删除的字符的开始位置 第二个 参数是可选的 表示要被删除的字符的个数 在我们的例子中 我们正在删除位置 pos 上的 字符 如果我们省略第二个参数 则 erase()将删除从 pos 到字符串结束的所有字符 下面是 filter_text()的完整实现 它有两个参数 指向包含文本的 string vector 的指针 以及含有要过滤的元素的 string 对象 void filter_text( vector *words, string filter ) { vector::iterator iter = words ->begin(); vector::iterator iter_end = words ->end(); // 如果用户没有提供 filter, 则缺省使用最小集 if ( ! filter.size() ) filter.insert( 0, "\".," ); while ( iter != iter_end ) { string::size_type pos = 0; // 对于找到的每个元素, 将其删除 while (( pos = (*iter).find_first_of( filter, pos )) != string::npos ) (*iter).erase(pos,1); iter++; } } 你知道为什么不随循环的每一次迭代递增 pos 吗 也就是下列代码为什么是不正确的 码 while (( pos = (*iter).find_first_of( filter, pos )) != string::npos ) { (*iter).erase(pos,1); ++pos; // 下正确 ... } 238 第六章 抽象容器类型 pos表示 string 中的位置 例如 已知字符串 thing," 循环的第一次迭代向 pos 赋值 5 即逗号的位置 在去掉逗号之后 字符串变成 thing" 位置 5 现在是双引号 如果我们已经递增 pos 那么将不能找到并去掉这个标点符号 下面给出怎样在主程序中调用 filter_text() string filt_elems( "\",.;:!?)(\\/" ); filter_text( text_locations ->first, filt_elems ); 最后 下面是我们的文本中的一些字符串的跟踪实例 在该文本中找到了一个或多个过 滤元素 filter_text: untamed. found! : pos: 7. after: untamed filter_text: "Daddy, found! : pos: 0" after: Daddy, found! : pos: 5, after: Daddy filter_text: thing," found! : pos: 5, after: thing" found! : pos: 5" after: thing filter_text: "I found! : pos: 0" after: I filter_text: Daddy, found! : pos: 5, after: Daddy filter_text: there?" found! : pos: 5? after: there" found! : pos: 5" after: there 练习 6.15 写一个程序 已知下列字符串 "/.+(STL).*$1/" 先用 erase(pos.count) 然后冉用 erase(iter,iter)去掉除了 STL 外的所有字符 239 第六章 抽象容器类型 练习 6.16 写一个程序 它能够接受下列定义 string sentence( "kind of" ); string s1( "whistle" ); string s2( "pixie" ); 各种 string 插入函数 得出值为 "A whistling-dixie kind of walk." 的句子 6.10 任意其他格式的字符串 文本查询系统的一件麻烦事情就是需要识别不同时态的同一个词 如 cry cries 和 cried 不同数目的同一个词 如 baby babies 以及大小写不同的同一个词 如 home 和 Home 前 两种情况属于单词后缀问题 虽然后缀问题超出了本书的范围 但是 下面的小示例方案给 出了 string 类操作的一个很好的练习 在进入后缀问题之前 我们先来解决大小写字母的简单情形 我们不想处理各种特殊的 情况 而只是简单地用小写形式来代替所有的大写字母 我们的实现看起来是这样的 void strip_caps( vector *words ) { vector::iterator iter = words ->begin(); vector::iterator iter_end = words ->end(); string caps( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ); while ( iter != iter_end ) { string::size_type pos = 0; while (( pos = (*iter).find_first_of( caps, pos )) != string::npos ) (*iter)[ pos ] = tolower( (*iter)[pos] ); ++iter; } } 以下函数 tolower( (*iter)[pos] ); 是标准 C 库函数 它接受一个大写字符 并返回与之等价的小写字母 为了使用它 我 们必须包含头文件 tolower( (*iter)[pos] ); 这个文件也包含其他函数的声明 如 isalpha() isdigit() ispunct() isspace() toupper() 以及其他一些函数 完整的列表和讨论请参见 PLAUGER92 标准 C++库定义了一个 ctype 类 它封装了标准 C 库函数的功能以及一组非成员函数 如 toupper() tolower()等等 为了 240 第六章 抽象容器类型 使用它们 我们必须包含标准 C++头文件 #include 然而 当本书正在写作时 我们还无法得到支持这种实现的 C++编译器 所以我们使用 标准 C 的实现方式 后缀问题很难完全解决 但是 完美的实现会显著改善我们查询单词集合的质量和大小 我们的实现只处理以 s 结尾的单词 void suffix_text( vector *words ) { vector::iterator iter = words->begin(), iter_end = words->end(); while ( iter != iter_end ) { // 如果只有 3 个字符或者更少 则不加处理 if ( (*iter).size() <= 3 ) { ++iter; continue; } if ( (*iter)[ (*iter).size ()-1 ] == 's' ) suffix_s( *iter ); // 其他后缀的处理 比如 ed ing ly 等 ++iter; } } 一种简单的做法是不要理会少于四个字符的单词 这使我们免于处理 has its is 等等 但是 却不能匹配像 tv 和 tvs 这样的词 如果单词以 ies 结尾 如 babies 和 cries 则我们需要用 y 代替 ies string::size_type pos3 = word.size()-3; string ies( "ies" ); if ( ! word.compare( pos3, 3, ies )) { word.replace( pos3, 3, 1, 'y' ); return; } 如果两个字符串的比较结果相等 则 compare()返回 0 pos3 表示 word 中开始比较的位 置 第二个参数 本例中为 3 表示从 pos3 开始的子字符串的长度 第三个参数是实际与之 比较的字符串 compare()实际上有六个版本 我们将在下节简要介绍其他版本 replace()用一个或多个替换字符来替换字符串中的一个或多个字符 在本例中 我们用 单个字符 y 代替三个字符的子串 ies replace()有十个重载的实例 我们将在下节简要 回顾它们 类似地 如果单词以 ses 结尾 比如 promises 和 purposes 中的 ses 则我们只要 241 第六章 抽象容器类型 去掉尾部 es 即可18 string ses( "ses" ); if ( ! word.compare( pos3, 3, ses )) { word.erase( pos3+1, 2 ); return; } 如果单词以 ous 结尾 如 oblivious fulvous 和 cretaceous 则我们什么都不做 类似 地 如果单词以 is 结尾 如 genesis mimesis 和 hepatitis 我们也是什么都不做 然而 这个系统并不是很完美的 例如 对于 Kimis 则需要我们去掉最后的 s 此外 如果 单词以 ius 结尾 如 genius 或者以 ss 结尾 如 hiss lateness 或 less 则我们什么都 不做 为了决定是否什么都不做 我们使用第一种形式的 compare() string::size_type spos = 0; string::size_type pos3 = word.size()-3; // "ous", "ss", "is", "ius" string suffixes( "oussisius" ); if ( ! word.compare( pos3, 3, suffixes, spos, 3 ) || // ous ! word.compare( pos3, 3, suffixes, spos+6, 3 ) || // ius ! word.compare( pos3+1, 2, suffixes, spos+2, 2 ) || // ss ! word.compare( pos3+1, 2, suffixes, spos+4, 2 )) // is return; 否则 我们只是简单地去掉 s // erase ending 's' word.erase( pos3+2 ); 有一些名字 比如 Pythagoras Brahms 和前拉斐尔派画家 Burne Jones 都在此通用规则 之外 当引入 set 关联容器类型时 我们将处理它们——实际上是把它留给读者作练习 在转向 map 和 set 关联容器类型之前 我们将在下节中简要介绍 string 的其他一些操作 练习 6.17 我们的程序不处理以 ed 结尾的后缀 如 surprised ly 结尾 如 surprisingly 以及 ing 结 尾 如 surprising 把下列后缀处理程序之一加到程序中 (a) suffix_ed(); (b) suffix_ly; (c) suffix_ing() 6.11 其他 string 操作 erase()的第二种形式用一对迭代器 iterator 作参数 标记出要被删除的字符的范围 例如 已知 string string name( "AnnaLiviaPlurabelle" ); 18 当然 也会有例外 例如 crises 按我们的做法 就变成了 cris 喔 242 第六章 抽象容器类型 我们来生成字符串 Annabelle typedef string::size_type size_type; size_type startPos = name.find( 'L' ); size_type endPos = name.find_last_of( 'a' ); name.erase( name.begin()+startPos, name.begin()+endPos ); 由第二个 iterator 指向的字符不属于要被删除的字符范围 这意味着我们将产生 Annaabelle 而不是 Annabelle 最后 第三种形式只带一个 iterator 作参数 它标记出要被删除的字符 我们给它传递 endPos 来删除第二个重复的 a name.erase( endPos ); 这使 name 的值为 Annabelle insert()操作支持将另外的字符插入到指定的位置 它的一般格式是 string_object.insert( position, new_string ); 这里 position 表示在 string_object 中插入 new_string 的位置 new_string 可以是 string C 风格字符串或者单个字符 例如 string string_object( "Missisippi" ); string::size_type pos = string_object.find( "isi" ); string_object.insert( pos+1, "s" ); insert()操作也支持插入 new_string 的一个子部分 例如 string new_string ( "AnnaBelle Lee" ); string_object += ' '; // 追加一个空格 // 找到 new_string 中开始和结束处的位置 pos = new_string.find( 'B' ); string::size_type posEnd = new_string.find( ' ' ); string_object.insert( string_object.size(), // string_object 中的位置 new_string, pos, // new_string 的开始位置 posEnd-pos // 要拷贝字符的数目 ) 现在 string_object 含有字符串 Mississippi Belle 如果我们希望插入从 pos 开始的 整个 new_string 则可以省略 posEnd 值 已知下列两个字符串 string s1( "Mississippi" ); string s2( "Annabelle" ); 我们希望利用它们来创建第三个字符串 其值为 MissAnna 应该怎样做呢 一种方法是用 assign()和 append()字符串操作 它们允许我们顺次地把一个 string 对象的 部分拷贝或连接到另一个 string 对象上 例如 string s3; 243 第六章 抽象容器类型 // 拷贝 s1 的前 4 个字符 s3.assign( s1, 0, 4 ); s3现在的值为 Miss // 连接一个空格 s3 += ' '; s3现在的值为 Miss // 连接 s2 的前 4 个字符 s3.append( s2, 0, 4 ); s3现在的值为 Miss Anna 另外 我们也可以把它写成 s3.assign( s1, 0, 4 ).append( ' ' ).append( s2, 0, 4 ); 如果我们希望抽出字符串的一部分 但不是从头开始 那么可以使用另外一种形式 它 用两个整型值作参数 开始位置和长度 位置从 0 开始计数 例如 为了从 Annabelle 中 抽取 belle 我们指定开始位置 4 和长度 5 string beauty; // 给 beauty 赋值"belle" beauty.assign( s2, 4, 5 ); 使用另外一种形式则不用提供位置和长度 而是提供一个 iterator 对 例如 // 给 beauty 赋值"belle" beauty.assign( s2, s2.begin()+4, s2.end() ); 在下面的例子中 两个字符串分别表示当前的任务和待处理的任务 我们需要定期随项 目的更换交换两者 例如 string current_project( "C++ Primer, 3rd Edition" ); string pending_project( "Fantasia 2000, Firebird segment" ); swap()操作交换两个 string 的值 每次调用 current_project.swap( pending_project ); 都会交换两个 string 对象的值 已知字符串 string first_novel( "V" ); 则下标 char ch = first_novel[ 1 ]; 返回一个未定义的字符串 因为索引值超出了范围 first_novel 长度为 1 由值 0 索引 下标操作符不提供范围检查 对于高质量的代码我们也不希望它这样做 如 int elem_count( const string &word, char elem ) { int occurs = 0; 244 第六章 抽象容器类型 // 很好: 不需要检查边界 for ( int ix=0; ix < word.size(); ++ix ) if ( word[ ix ] == elem ) ++occurs; return occurs; } 但是 对可能含有错误定义的代码 如 void mumble( const string &st, int index ) { // 潜在的范围错误 char ch = st[ index ]; // ... } 另一个可替代的 at()操作提供了运行时刻对索引值的范围检查 如果索引是有效的 则 at()返回相关的字符元素 与下标操作符的方式相同 但是 如果索引无效 则 at()抛出 out_of_range 异常 void mumble( const string &st, int index ) { try { char ch = st.at(index); // ... } catch( std::out_of_range ) { ... } // ... } 任意两个不相等的字符串都有一个字典顺序 例如 已知下列两个字符串 string cobol_program_crash( "abend" ); string cplus_program_crash( "abort" ); cobol_program_crash 字符串对象小于 cplus_program_crash 字符串对象 这是通过比较 第 一个不相等的字符得到的 在英文字母表中 e 出现在 o 前面 compare()字符串操作提供了两个字符串的字典序比较 给定 s1.compare( s2 ); 则 compare()返回三个可能值之一 1 如果 s1 大于 s2 则 compare()返回一个正值 2 如果 s1 小于 s2 则 compare()返回一个负值 3 如果 s1 等于 s2 则 compare()返回 0 例如 cobol_program_crash.compare( cplus_program_crash ); 返回一个负值 而 245 第六章 抽象容器类型 cplus_program_crash.compare( cobol_program_crash ); 返回一个正值 string 关系操作符 < > != == <=和>= 给出了 compare()操作的一 种替代简短表示 compare()操作有六个重载版本 利用这些比较函数 我们可以标记出其中一个或者两个 字符串的子串以进行比较 在上一节关于后缀的讨论中我们已经看到过一些例子 replace()提供了十种方式 使我们可以用一个或多个字符替换字符串中的一个或多个现 有的字符 现有字符与替换字符的数目可以不等 replace()操作有两种基本格式 各种变 化形式主要在于如何标记出要被替换的字符集合 在第一种格式中 前两个参数给出了指向 字符集开始的索引以及要被替换的字符的个数 在第二种格式中 传递了一对 iterator 分别 标记出字符集的开始位置以及要被替换的最后一个字符的下一位置 下面是第一种格式的例 子 string sentence( "An ADT provides both interface and implementation." ); string::size_type position = sentence.find_last_of( 'A' ); string::size_type length = 3; // 用 Abstract Data Type 代替 ADT sentence.replace( position, length, "Abstract Data Type" ); 第一个参数代表开始位置 第二个参数代表从 position 开始的字符串的长度 因此 长 度是 3 而不是 2 表示字符串 ADT 第三个参数代表新的字符串 有许多变化形式可以 用来指定这个新字符串 例如 下面这个变种版本用一个 string 对象而不是 C 风格字符串作 参数 string new_str( "Abstract Data Type" ); sentence.replace( position, length, new_str ); 下面的变种版本插入由位置和长度标记的新字符串的子串 #include typedef string::size_type size_type; // 得到 3 个单词的位置 size_type posA = new_str.find( 'A' ); size_type posD = new_str.find( 'D' ); size_type posT = new_str.find( 'T' ); // ok: 用"Type"代替 T sentence.replace( position+2, 1, new_str, posT, 4 ); // ok: 用"Date" 代替 D sentence.replace( position+1, 1, new_str, posD, 5 ); // ok: 用"Abstract"代替 A sentence.replace( position, 1, new_str, posA, 9 ); 另外一种版本是专门为 用一个指定重复次数的单个字符替换一个子串 而提供的 例 如 246 第六章 抽象容器类型 string hmm( "Some celebrate Java as the successor to C++." ); string::size_type position = hmm.find( 'J' ); // ok: 用 xxxx 代替 Java hmm.replace( position, 4, 4, 'x'); 我们要说明的最后一种版本是用一个字符数组的指针和长度来标记新串 例如 const char *lang = "EiffelAda95JavaModula3"; int index[] = { 0, 6, 11, 15, 22 }; string ahhem( "C++ is the language for today's power programmers." ); ahhem.replace(0, 3, lang+index[1], index[2]-index[1]); 下面是第二种格式的例子 它用一对 iterator 来标记要被替换的目标子串 string sentence( "An ADT provides both interface and implementation." ); // 指向 ADT 的'A' string::iterator start = sentence.begin()+3; // 用 Abstract Data Type 代替 ADT sentence.replace( start, start+3, "Abstract Data Type" ); 另外四种变种形式允许替换串是 string 对象 一个字符重复 N 次 一对 iterator 或 C 风 格字符串中的 N 个字符被用作替换字符集 关于 string 操作我们就介绍到这里 更详细完整的信息 请参见 C++标准定义 ISO_C++97 在写这本书的时候 还没有比较好一点的关于标准 C++库的参考资料 练习 6.18 写一个程序 使它能接受下列两个字符串 string quote1( "When lilacs last in the dooryard bloom'd" ); string quote2( "The child is father of the man" ); 用 assign()和 append()操作构造字符串 string sentence( "The child is in the dooryard" ); 练习 6.19 写一个程序 已知字符串 string generic1( "Dear Ms Daisy:" ); string generic2( "MrsMsMissPeople" ); 实现下面的函数 string generate_salutation( string generic1, string lastname, string generic2, string::size_type pos, 247 第六章 抽象容器类型 int length ); 请使用 replace()操作 这里用 lastname 代替 Daisy 用 pos 索引 generic2 并且长度为 length 的字符替换 Ms 例如 string lastName( "AnnaP" ); string greetings = generate_salutation( generic1, lastName, generic2, 5, 4 ); 返回字符串 Dear Miss AnnaP: 6.12 生成文本位置 map 本节中我们将为文本中每个单词建立一个行列位置集合 以此引入并探讨关联容器类型 map 在下节中 我们将建立一个单词排除集 以此引入并探讨关联容器 set 一般地 当我们只想知道一个值是否存在时 set 最有用处 希望存储 也可能修改 一个相关的值时 map 最为有用 在这两种情况下 元素都是以有序关系存储的 以此支持高效率的存储和检 索 在 map 也叫关联数组 associative array 中 我们提供一个 键/值 对 键用来索引 map 而值用作被存储和检索的数据 在我们的程序示例中 每个 string 对象用作键 行列 位置的 vector 用作值 为访问位置 vector 我们用下标操作符索引 map 例如 string query( "pickle" ); vector< location > *locat; // 返回与"pickle"相关的 vector* locat = text_map[ query ]; map的键类型——本例中为 string——用作索引 相关的 location*值被返回 为了使用 map 我们必须包含相关的头文件 #include 在使用 map 和 set 时 两个最主要的动作是向里面放入元素 以及查询元素是否存在 在下一小节中 我们将看到怎样定义和插入键/值对 在其后的小节中 我们将了解怎样发现 一个元素是否存在 若存在 又怎样获取它的值 6.12.1 定义并生成 map 为定义 map 对象 我们至少要指明键和值的类型 例如 map word_count; 定义了 map 对象 word_count 它由 string 作为索引 并拥有一个相关的 int 值 类似地 class employee; map personnel; 定义了 map 对象 personnel 它由一个 int 作为索引 代表一个惟一的雇员号 并拥有相 248 第六章 抽象容器类型 关联的指向雇员类实例的指针 对于我们的文本查询系统 map 声明如下 typedef pair location; typedef vector loc; map text_map; 因为在写作本书时 我们能使用的编译器都不支持模板参数的缺省参数 所以 在实际 中 我们必须提供下列扩展的定义 map, // 用作排序的关系操作符 allocator> // 缺省的内存分配操作符 text_map; 缺省情况下 关联容器类型用小于操作符排序 然而 我们总是可以改变它 只需提供 一个其他可替换的关系操作符 见 12.3 节函数对象 定义了 map 以后 下一步工作就是加入键/值元素对 直观上 我们希望这样写代码 #include #include map word_count; word_count[ string("Anna") ] = 1; word_count[ string("Danny") ] = 1; word_count[ string("Beth") ] = 1; // 等等 当我们写如下语句时 word_count[ string("Anna") ] = 1; 将发生以下事情 1 一个未命名的临时 string 对象被构造并传递给与 map 类相关联的下标操作符 这个 对象用 Anna 初始化 2 在 word_count 中查找 Anna 项 没有找到 3 一个新的键/值对被插入到 word_count 中 当然 键是一个 string 对象 持有 Anna 但是 值不是 1 而是 0 4 插入完成 接着值被赋为 1 通过下标操作符把一个键插入到 map 中时 而相关联的值被初始化为底层元素类型的缺 省值 内置数值类型的缺省值为 0 实际上 用下标操作符把 map 初始化至一组元素集合 会使每个值都被初始化为缺省值 然后再被赋值为显式的值 如果元素是类对象 而且它的缺省初始化和赋值的运算量都很大 就会影响程序的性能 尽管不会影响程序的正确性 一种比较好的插入单个元素的方法如下所示 // the preferred single element insertion method word_count.insert( map:: 249 第六章 抽象容器类型 value_type( string("Anna"), 1 ) ); map定义了一个类型 value_type 表示相关联的键值对 下面一行代码 map< string,int >:: value_type( string("Anna"), 1 ) 其作用是创建一个 pair 对象 接着将其直接插入 map 为了便于阅读 我们使用 typedef typedef map::value_type valType; 使用它 插入操作看起来就不那么复杂了 word_count.insert( valType( string("Anna"), 1 )); 为插入一定范围内的键/值元素 我们可以用另一个版本的 insert()方法 它用一对 iterator 作为参数 例如 map< string, int > word_count; // ... fill it up map< string, int > word_count_two; // 插入所有键/值对 word_count_two.insert(word_count.begin(),word_count.end()); 在本例中 我们也可以通过把第二个 map 对象初始化成第一个 以获得同样的效果 // 用所有键/值对的拷贝初始化 map< string, int > word_count_two( word_count ); 现在我们来浏览一下怎样建立起我们的文本 map 6.8 节讨论的 separate_words()创建了 两个 vector 文本中所有词的字符串 vector 以及相应的行列对的位置 vector 对于字符串 vector 中的每个单词元素 位置 vector 中的等价元素都分别给出了该词的行列信息 字符串 vector 为文本 map 提供了键的集合 而位置 vector 提供相关联的值集合 separate_words()返回一个 pair 对象 它拥有指向这两个 vector 的指针 这个 pair 对象是 我们的函数 build_word_map()的参数 返回值是文本位置 map——或指向它的指针 // typedefs to make declarations easier typedef pair< short,short > location; typedef vector< location > loc; typedef vector< string > text; typedef pair< text*,loc* > text_loc; extern map< string, loc* >* build_word_map( const text_loc *text_locations ); 第一个准备工作是从空闲存储区中分配一个空 map 以及从作为参数的 pair 对象中分离 出字符串和位置 vector map *word_map = new map< string, loc* >; vector *text_words = text_locations ->first; vector *text_locs = text_locations ->second; 接下来 我们需要并行迭代两个 vector 有两种情况需要考虑 250 第六章 抽象容器类型 1 map 中还没有单词 在这种情况下 需要插入键/值对 2 单词已经被插入 在这种情况下 需要用另外的行列信息修改该项的位置 vector 下面是我们的实现代码 register int elem_cnt = text_words ->size(); for ( int ix = 0; ix < elem_cnt; ++ix ) { string textword = ( *text_words )[ ix ]; // 排除策略: 如果少于 3 个字符, // 或在排除集合中存在, // 则不输入到 map 中. if ( textword.size() < 3 || exclusion_set.count( textword )) continue; // 判断单词是否存在 // 如果 count()返回 0 则不存在——加入它 if ( ! word_map->count((*text_words)[ix] )) { loc *ploc = new vector; ploc->push_back( (*text_locs)[ix] ); word_map->insert( value_type( (*text_words)[ix], ploc )); } else // 修改该项的位置向量 (*word_map)[(*text_words)[ix]]->push_back((*text_locs)[ix]); } 对于这个语法复杂的表达式 (*word_map)[(*text_words)[ix]]->push_back((*text_locs)[ix]); 如果我们把它分解成独立的部分可能更容易理解一些 // 得到要修改的单词 string word = (*text_word)[ix]; // 得到位置向量 vector *ploc = (*word_map)[ word ]; // 得到行列对 location loc = (*text_locs)[ix]; // 插入新的行列对 ploc->push_back(loc); 其余的语法复杂性是因为操纵指针而导致的 并不是因为 vector 本身 为直接应用下标 操作符我们不能写 string word = text_words[ix]; // 错误 相反 我们必须先解除指针的引用 string word = (*text_words)[ix]; // ok 251 第六章 抽象容器类型 最后 build_word_map 返回内部建好的 map return word_map; 下面给出怎样在 main()函数中调用它 int main() { // 读入并分离文本 vector *text_file = retrieve_text(); text_loc *text_locations = separate_words( text_file); // 处理单词 // ... // 生成单词/位置对并提示查询 map,allocator> *text_map = build_word_map( text_locations ); // ... } 6.12.2 查找并获取 map 中的元素 下标操作符给出了获取一个值的最简单方法 例如 // map word_count; int count = word_count[ "wrinkles" ]; 但是 只有当 map 中存在这样一个键的实例时 该代码才会表现正常 如果不存在这样 的实例 使用下标操作符会引起插入一个实例 在本例中 键/值对 string( "wrinkles" ), 0 被插入到 word_count 中 count 被初始化为 0 有两个 map 操作能够发现一个键元素是否存在 而且在键元素不存在时也不会引起插入 实例 1 Count(keyValue) count()返回 map 中 keyValue 出现的次数 当然 对于 map 而言 返回值只能是 0 或 1 如果返回值非 0 我们就可以安全地使用下标操作符 例如 int count = 0; if ( word_count.count( "wrinkles" )) count = word_count[ "wrinkles" ]; 2 Find(keyValue) 如果实例存在 则 find()返回指向该实例的 iterator 如果不存在 则返回等于 end()的 iterator 例如 int count = 0; map::iterator it = word_count.find( "wrinkles" ); if ( it != word_count.end() ) count = (*it).second; 指向 map 中元素的 iterator 指向一个 pair 对象 其中 first 拥有键 second 拥有值 我们 将在下一小节再次看到这一点 252 第六章 抽象容器类型 6.12.3 对 map 进行迭代 现在我们已经建立了自己的 map 接下去想输出它的内容 我们可以通过对 由 begin() 和 end()两个迭代器标记的所有元素 进行迭代 来做到这一点 下面是完成了此任务的函数 display_map_text() display_map_text( map *text_map ) { typedef map tmap; tmap::iterator iter = text_map->begin(), iter_end = text_map->end(); while ( iter != iter_end ) { cout << "word: " << (*iter).first << " ("; int loc_cnt = 0; loc *text_locs = (*iter).second; loc::iterator liter = text_locs->begin(), liter_end = text_locs->end(); while ( liter != liter_end ) { if ( loc_cnt ) cout << ','; else ++loc_cnt; cout << '(' << (*liter).first << ',' << (*liter).second << ')'; ++liter; } cout << ")\n"; ++iter; } cout << endl; } 如果 map 中没有任何元素 调用我们的显示函数也不会有任何麻烦 判断 map 是否为空 的一种办法是调用 size()函数 if ( text_map->size() ) display_map_text( text_map ); 但是 没有必要对所有的元素进行计数 我们可以更直接地调用 empty() if ( ! text_map->empty() ) display_map_text( text_map ); 253 第六章 抽象容器类型 6.12.4 单词转换 map 下面的小程序说明了怎样创建 查找和迭代一个 map 该程序使用了两个 map 而单词 转换 map 拥有两个 string 类型的元素 键 key 代表要求特殊处理的单词 值 value 表 示我们遇到该词时应该采用怎样的转换 为简单起见 我们把 map 的所有项都固定写在代码 中 作为练习 你可以泛化该程序 使其从标准输入或指定的文件读入 单词/转换 对 我们的统计 map 保存了被执行的转换的使用统计信息 程序如下 #include #include #include #include int main() { map< string, string > trans_map; typedef map< string, string >::value_type valType; // 第一个权宜之计: 将转换对固定写在代码中 trans_map.insert( valType( "gratz", "grateful" )); trans_map.insert( valType( "'em", "them" )); trans_map.insert( valType( "cuz", "because" )); trans_map.insert( valType( "nah", "no" )); trans_map.insert( valType( "sez", "says" )); trans_map.insert( valType( "tanx", "thanks" )); trans_map.insert( valType( "wuz", "was" )); trans_map.insert( valType( "pos", "suppose" )); // ok: 显示 trans_map map< string,string >::iterator it; cout << "Here is our transformation map: \n\n"; for ( it = trans_map.begin(); it != trans_map.end(); ++it ) cout << "key: " << (*it).first << "\t" << "value: " << (*it).second << "\n"; cout << "\n\n"; // 第二个权宜之计: 固定写入文字. string textarray[14]={ "nah", "I", "sez", "tanx", "cuz", "I", "wuz", "pos", "to", "not", "cuz", "I", "wuz", "gratz" }; vector< string > text( textarray, textarray+14 ); vector< string >::iterator iter; // ok: 显示 text cout << "Here is our original string vector: \n\n"; int cnt = 1; for ( iter = text.begin(); iter != text.end(); ++iter, ++cnt ) cout << *iter << ( cnt % 8 ? " " : "\n" ); cout << "\n\n\n"; 254 第六章 抽象容器类型 // 包含统计信息的 map——动态生成 map< string,int > stats; typedef map< string,int >::value_type statsValType; // ok: 真正的 map 工作——程序的核心 for ( iter = text.begin(); iter != text.end(); ++iter ) if (( it = trans_map.find( *iter )) != trans_map.end() ) { if ( stats.count( *iter )) stats[ *iter ] += 1; else stats.insert( statsValType( *iter, 1 )); *iter = (*it).second; } // ok: 显示被转换后的 vector cout << "Here is our transformed string vector: \n\n"; cnt = 1; for ( iter = text.begin(); iter != text.end(); ++iter, ++cnt ) cout << *iter << ( cnt % 8 << " " : "\n" ); cout << "\n\n\n"; // ok: 现在对统计 map 进行迭代 cout << "Finally, here are our statistics:\n\n"; map,allocator>::iterator siter; for ( siter = stats.begin(); siter != stats.end(); ++siter ) cout << (*siter).first << " " << "was transformed " << (*siter).second << ((*siter).second == 1 << " time\n" : " times\n" ); } 程序执行时产生如下输出 Here is our transformation map: key: 'em value: them key: cuz value: because key: gratz value: grateful key: nah value: no key: pos value: suppose key: sez value: says key: tanx value: thanks key: wuz value: was Here is our original string vector: nah I sez tanx cuz I wuz pos to not cuz I wuz gratz Here is our transformed string vector: no I says thanks because I was suppose to not because I was grateful 255 第六章 抽象容器类型 Finally, here are our statistics: cuz was transformed 2 times gratz was transformed 1 time nah was transformed 1 time pos was transformed 1 time sez was transformed 1 time tanx was transformed 1 time wuz was transformed 2 times 6.12.5 从 map 中删除元素 从 map 中删除元素的 erase()操作有三种变化形式 为了删除一个独立的元素 我们传递 给 erase()一个键值或 iterator 为了删除一列元素 我们传递给 erase()一对 lieator 例如 如 果我们打算让用户能从 text_map 删除元素 可以这样做 string removal_word; cout << "type in word to remove: "; cin >> removal_word; if ( text_map->erase( removal_word )) cout << "ok: " << removal_word << " removed\n"; else cout << "oops: " << removal_word << " not found!\n"; 另外 我们可以在删除单词之前 检查它是否存在 map::iterator where; where = text_map.find( removal_word ); if ( where == text_map->end() ) cout << "oops: " << removal_word << " not found!\n"; else { text_map->erase( where ); cout << "ok: " << removal_word << " removed!\n"; } 在 text_map 的实现中 我们存储了与每个单词相关联的多个位置 这种管理使实际位置 值的存储和查询复杂化 一种替代的做法是为每个位置插入一个单词项 但是 一个 map 只 能拥有一个键值的惟一实例 为了给同一个键提供多个项 我们必须使用 multimap 6.15 节 将讲解关联容器类型 multimap 练习 6.20 定义一个 map 它以家族姓氏为索引 以各家孩子名的 vector 作为值 向 map 中放入至 少六项 通过下列动作来测试它 基于家族姓氏的用户查询 把孩子加入到一个家庭中 为 另一个家庭加入三个孩子以及输出全部 map 项 练习 6.21 扩展练习 6.20 中的 map 使其具有存储字符串对的 vector 孩子的名字和生日 改写练 习 6.20 的实现 使其支持新的字符串对 vector 修改你的测试程序 并验证其正确性 256 第六章 抽象容器类型 练习 6.22 列出可能的三种应用 它们都会用到 map 写出每个 map 的定义 说明怎样插入元素和 获取元素 6.13 创建单词排除集 map中键/值对构成 好比一个地址和电话号码以人名为键值 相反地 set 只是键的集 合 例如 一个公司可能定义一个集合 bad_checks 由在过去两年中有过不良账单的人名构 成 当只想知道一个值是会存在时 set 是最合适的 例如 在接受我们的账单之前 公司可 能想查询 bad_checks 看看是否存在我们的名字 我们为自己的文本查询系统创建了一个单词排除 set 它包括没有语义的词 如 the and into with 和 but 等等 虽然这大大改善了单词索引的质量 但是它使我们无法定位到哈姆 雷特的著名讲演的第一行 To be or not to be 我们在向 map 中输入元素之前 首先检查 它是否出现在排除集中 如果是 则不把它放到 map 中 6.13.1 定义 set 并放入元素 为了定义和使用关联容器类型 set 我们必须包含其相关的头文件 #include 下面是单词排除集合对象的定义 set exclusion_set; 用 insert 操作将单个元素插入到 set 中 例如 exclusion_set.insert( "the" ); exclusion_set.insert( "and" ); 另外 我们可以通过向 insert()提供一对 iterator 以便插入一个元素序列 例如 我们的 文件查询系统允许用户指定一个单词文件 文件中的所有单词都将排除在 map 之外 如果用 户不提供这样的文件 我们就用缺省的单词集填充单词排除 set typedef set< string >::difference_type diff_type; set< string > exclusion_set; ifstream infile( "exclusion_set" ); if ( ! infile ) { static string default_excluded_words[25] = { "the","and","but","that","then","are","been", "can","can't","cannot","could","did","for", "had","have","him","his","her","its","into", "were","which","when","with","would" }; cerr << "warning! unable to open word exclusion file! -- " << "using default set\n"; 257 第六章 抽象容器类型 copy( default_excluded_words, default_excluded_words+25, inserter( exclusion_set, exclusion_set.begin() )); } else { istream_iterator\string,diff_type> input_set(infile),eos; copy( input_set, eos, inserter( exclusion_set, exclusion_set.begin() )); } 这段代码引入了两个我们没有见过的元素 difference_type 和 inserter 类 difference_type 是字符串 set 中两个 iterator 相减的结果类型 istream_iterator 用它作参数 copy()是一个泛型算法 在第 12 章和附录中详细讨论 它的前两个参数或者是 iterator 或者是指针 它们标记了要拷贝元素的范围 第二个参数或是 iterator 或是指针 它们指向目 标容器中这些元素要被放置的起始处 问题是 copy()期望容器的长度大于或等于要拷贝元素的个数 这是因为 copy()顺次赋值 每个元素 它并没有插入元素 但是 关联容器不支持预分配长度 为了把元素拷贝到排除 集中 必须使 copy()能插入而不是为每个元素赋值 inserter 类完成的正是这项工作 12.4 节 将详细讨论 6.13.2 搜索一个元素 查询 set 对象中是否存在一个值的两个操作是 find()和 count() 如果元素存在 则 find() 返回指向这个元素的 iterator 否则返回一个等于 end()的 iterator 表示该元素不存在 如果 找到元素 count()返回 1 如果元素不存在 则返回 0 在 build_word_map()函数中 我们在 向 map 中输入单词之前 增加了对 exclusion_set 的测试 if ( exclusion_set.count( textword )) continue; // ok: 把单词加入到 map 中 6.13.3 迭代一个 set 对象 为了练习我们的 单词/位置 map 我们实现了一个小函数 它允许查询单个单词 第 17 章将提议对完整查询语言的支持 如果找到了单词 我们希望显示该词出现的行 但是 一个词可能在一行中出现多次 比如 tomorrow and tomorrow and tomorrow 我们希望只显示该行一次 实现每行只保留一个实例的一种策略就是使用 set 如下列代码段 // 获得指向位置向量的指针 loc *ploc = (*text_map)[ query_text ]; // 对 "位置项对" 进行迭代 // 把每行插入到 set 中 set< short > occurrence_lines; loc::iterator liter = ploc->begin(), liter_end = ploc->end(); 258 第六章 抽象容器类型 while ( liter != liter_end ) { occurrence_lines.insert( occurrence_lines.end(), (*liter).first ); ++liter; } set只能含有每个键值的惟一实例 所以 occurrence_line 保证只包含单词出现行的单 实例 为了显示这些文本行 只需迭代 set register int size = occurrence_lines.size(); cout << "\n" << query_text << " occurs " << size << (size == 1 << " time:" : " times:") << "\n\n"; set< short >::iterator it=occurrence_lines.begin(); for ( ; it != occurrence_lines.end(); ++it ) { int line = *it; cout << "\t( line " << line + 1 << " ) " << (*text_file)[line] << endl; } query_text()的完整实现将在下节给出 set支持操作 size() empty()和 erase() 同上节描述的 map 类型相同 另外 泛型算法提 供了一组 set 特有的函数 如 set_union()和 set_difference() 我们将在第 17 章利用它们来支 持查询语言 练习 6.23 增加一个排除集 用来识别以 s 结尾 但结尾不应去掉 又没有一般规则可循的单词 例如 放在该集合中的三个词可能是名字 Pythagoras Brahms 和 Burne_Jones 把这个排除 集的用法放入 6.10 节的 suffix_s()中 练习 6.24 建立一个 vector 里面是你下六个月里想看的书 以及一个 set 里面是你已经看过的书 的题目 写一个程序 它从 vector 中为你选择一本没有读过的书 当它选择了一本书之后 应该把该书的题目放到 set 中 如果实际上你把该书放在一边没有看 它应该支持从已读书 目的 set 中去掉这本书 六个月后 输出已读的书和还没有读的书 6.14 完整的程序 本节将给出在这一章开发的 完整有效的程序 其中有两个修改 我们没有按照过程化 程序设计的方式把数据结构和函数分开 而是引入了一个类 TextQuery 来封装它们 我们将 在后面章节中更详细地了解类的使用 文本的表示也做了修改 以便能够在当前可用的编 259 第六章 抽象容器类型 译器下通过编译 例如 iostream 库反映了标准 C++之前的实现版本 指编译器 模板不支 持模板参数的缺省值 为使程序能在你当前的系统上运行 或许需要修改某些声明 // 标准库头文件 #include #include #include #include #include #include // 标准 C++之前的 iostream 头文件 #include // 标准 C 头文件 #include #include // typedefs 使声明更简单 typedef pair location; typedef vector loc; typedef vector text; typedef pair text_loc; class TextQuery { public: TextQuery() { memset( this, 0, sizeof( TextQuery )); } static void filter_elements( string felems ) { filt_elems = felems; } void query_text(); void display_map_text(); void display_text_locations(); void doit() { retrieve_text(); separate_words(); filter_text(); suffix_text(); strip_caps(); build_word_map(); } private: void retrieve_text(); void separate_words(); void filter_text(); void strip_caps(); void suffix_text(); void suffix_s( string& ); void build_word_map(); private: vector *lines_of_text; 260 第六章 抽象容器类型 text_loc *text_locations; map< string,loc*, less,allocator> *word_map; static string filt_elems; }; string TextQuery::filt_elems( "\",.;:!<<)(\\/" ); int main() { TextQuery tq; tq.doit(); tq.query_text(); tq.display_map_text(); } void TextQuery:: retrieve_text() { string file_name; cout << "please enter file name: "; cin >> file_name; ifstream infile( file_name.c_str(), ios::in ); if ( !infile ) { cerr << "oops! unable to open file " << file_name << " -- bailing out!\n"; exit( - 1 ); } else cout << "\n"; lines_of_text = new vector; string textline; while ( getline( infile, textline, '\n' )) lines_of_text->push_back( textline ); } void TextQuery:: separate_words() { vector *words = new vector; vector *locations = new vector; for ( short line_pos = 0; line_pos < lines_of_text->size(); line_pos++ ) { short word_pos = 0; string textline = (*lines_of_text)[ line_pos ]; string::size_type eol = textline.length(); string::size_type pos = 0, prev_pos = 0; 261 第六章 抽象容器类型 while (( pos = textline.find_first_of( ' ', pos )) != string::npos ) { words->push_back( textline.substr( prev_pos, pos - prev_pos )); locations->push_back( make_pair( line_pos, word_pos )); word_pos++; pos++; prev_pos = pos; } words->push_back( textline.substr( prev_pos, pos - prev_pos )); locations ->push_back(make_pair(line_pos,word_pos)); } text_locations = new text_loc( words, locations ); } void TextQuery:: filter_text() { if ( filt_elems.empty() ) return; vector *words = text_locations ->first; vector::iterator iter = words ->begin(); vector::iterator iter_end = words ->end(); while ( iter != iter_end ) { string::size_type pos = 0; while (( pos = (*iter).find_first_of( filt_elems, pos )) != string::npos ) (*iter).erase(pos,1); ++iter; } } void TextQuery:: suffix_text() { vector *words = text_locations ->first; vector::iterator iter = words ->begin(); vector::iterator iter_end = words ->end(); while ( iter != iter_end ) { if ( (*iter).size() <= 3 ) { iter++; continue; } 262 第六章 抽象容器类型 if ( (*iter)[ (*iter).size()- 1 ] == 's' ) suffix_s( *iter ); // 其他的后缀处理放在这里 iter++; } } void TextQuery:: suffix_s( string &word ) { string::size_type spos = 0; string::size_type pos3 = word.size()- 3; // "ous", "ss", "is", "ius" string suffixes( "oussisius" ); if ( ! word.compare( pos3, 3, suffixes, spos, 3 ) || ! word.compare( pos3, 3, suffixes, spos+6, 3 ) || ! word.compare( pos3+1, 2, suffixes, spos+2, 2 ) || ! word.compare( pos3+1, 2, suffixes, spos+4, 2 )) return; string ies( "ies" ); if ( ! word.compare( pos3, 3, ies )) { word.replace( pos3, 3, 1, 'y' ); return; } string ses( "ses" ); if ( ! word.compare( pos3, 3, ses )) { word.erase( pos3+1, 2 ); return; } // 去掉尾部的 's' word.erase( pos3+2 ); // watch out for "'s" if ( word[ pos3+1 ] == '\'' ) word.erase( pos3+1 ); } void TextQuery:: strip_caps() { vector *words = text_locations ->first; vector::iterator iter = words ->begin(); vector::iterator iter_end = words ->end(); 263 第六章 抽象容器类型 string caps( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ); while ( iter != iter_end ) { string::size_type pos = 0; while (( pos = (*iter).find_first_of( caps, pos )) != string::npos ) (*iter)[ pos ] = tolower( (*iter)[pos] ); ++iter; } } void TextQuery:: build_word_map() { word_map = new map< string, loc*, less, allocator >; typedef map,allocator>::value_type value_type; typedef set,allocator>::difference_type diff_type; set,allocator> exclusion_set; ifstream infile( "exclusion_set" ); if ( !infile ) { static string default_excluded_words[25] = { "the","and","but","that","then","are","been", "can","can't","cannot","could","did","for", "had","have","him","his","her","its","into", "were","which","when","with","would" }; cerr << "warning! unable to open word exclusion file! -- " << "using default set\n"; copy( default_excluded_words, default_excluded_words+25, inserter( exclusion_set, exclusion_set.begin() )); } else { istream_iterator< string, diff_type > input_set( infile ), eos; copy( input_set, eos, inserter( exclusion_set, exclusion_set.begin() )); } // 遍历单词, 输入键/值对 vector *text_words = text_locations ->first; vector *text_locs = text_locations ->second; 264 第六章 抽象容器类型 register int elem_cnt = text_words ->size(); for ( int ix = 0; ix < elem_cnt; ++ix ) { string textword = ( *text_words )[ ix ]; if ( textword.size() < 3 || exclusion_set.count( textword )) continue; if ( ! word_map->count((*text_words)[ix] )) { // 没有, 添加: loc *ploc = new vector; ploc->push_back( (*text_locs)[ix] ); word_map->insert( value_type( (*text_words)[ix], ploc )); } else (*word_map)[(*text_words)[ix]]-> push_back( (*text_locs)[ix] ); } } void TextQuery:: query_text() { string query_text; do { cout << "enter a word against which to search the text.\n" << "to quit, enter a single character ==> "; cin >> query_text; if ( query_text.size() < 2 ) break; string caps( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ); string::size_type pos = 0; while (( pos = query_text.find_first_of( caps, pos )) != string::npos ) query_text[ pos ] = tolower( query_text[pos] ); // 如果对 map 索引, 输入 query_text, 如无 // 说明没有要找的词 if ( !word_map->count( query_text )) { cout << "\nSorry. There are no entries for " << query_text << ".\n\n"; continue; } loc *ploc = (*word_map)[ query_text ]; set,allocator> occurrence_lines; loc::iterator liter = ploc->begin(), liter_end = ploc->end(); while ( liter != liter_end ) { occurrence_lines.insert( 265 第六章 抽象容器类型 occurrence_lines.end(), (*liter).first); ++liter; } register int size = occurrence_lines.size(); cout << "\n" << query_text << " occurs " << size << (size == 1 << " time:" : " times:") << "\n\n"; set,allocator>::iterator it=occurrence_lines.begin(); for ( ; it != occurrence_lines.end(); ++it ) { int line = *it; cout << "\t( line " // 不要用从 0 开始有 // 文本行把用户弄迷糊了 << line + 1 << " ) " << (*lines_of_text)[line] << endl; } cout << endl; } while ( ! query_text.empty() ); cout << "Ok, bye!\n"; } void TextQuery:: display_map_text() { typedef map,allocator> map_text; map_text::iterator iter = word_map->begin(), iter_end = word_map->end(); while ( iter != iter_end ) { cout << "word: " << (*iter).first << " ("; int loc_cnt = 0; loc *text_locs = (*iter).second; loc::iterator liter = text_locs->begin(), liter_end = text_locs->end(); while ( liter != liter_end ) { if ( loc_cnt ) cout << ","; else ++loc_cnt; cout << "(" << (*liter).first << "," << (*liter).second << ")"; ++liter; } 266 第六章 抽象容器类型 cout << ")\n"; ++iter; } cout << endl; } void TextQuery:: display_text_locations() { vector *text_words = text_locations ->first; vector *text_locs = text_locations ->second; register int elem_cnt = text_words ->size(); if ( elem_cnt != text_locs->size() ) { cerr << "oops! internal error: word and position vectors " << "are of unequal size \n" << "words: " << elem_cnt << " " << "locs: " << text_locs->size() << " -- bailing out!\n"; exit( - 2 ); } for ( int ix = 0; ix < elem_cnt; ix++ ) { cout << "word: " << (*text_words)[ ix ] << "\t" << "location: (" << (*text_locs)[ix].first << "," << (*text_locs)[ix].second << ")" << "\n"; } cout << endl; } 练习 6.25 说明为什么要用专门的 inserter 迭代器来向单词排除 set 中加入元素 这在 6.13.1 节有 简要解释 将在 12.4.1 节详细讨论 set exclusion_set; ifstream infile( "exclusion_set" ); // ... copy( default_excluded_words, default_excluded_words+25, inserter( exclusion_set, exclusion_set.begin() )); 练习 6.26 我们原先的实现反映了过程化的解决方案——即一组全局函数 它们在一组未经封装的 267 第六章 抽象容器类型 独立数据结构上进行操作 最终的程序反映了另外一种解决方案 它把函数和数据结构封装 在 TextQuery 类中 比较两种方式 它们的缺点和长处各是什么 练习 6.27 在程序的这个版本中 用户被提示输入待处理的文本文件 更方便的实现会允许用户在 程序命令行中指定文件——我们将在第 7 章中看到怎样支持程序的命令行参数 我们的程序 应该支持其他哪些命令行选项 6.15 multimap 和 multiset map和 set 只能包含每个键的单个实例 而 multiset 和 multimap 允许要被存储的键出现 多次 例如 在电话目录中 有人可能希望为每个人相关联的每个电话号码提供单独的列 表 按作者给出的文本列表可能要为每个题目提供单独的资料 或为文本中每个单词的每 次出现给出单独的位置对 要使用 multimap 和 mulitset 我们必须包含相关的 map 和 set 头文件 #include multimap< key_type, value_type > multimapName; // 按 string 索引, 存有 list multimap< string, list< string > > synonyms; #include multiset< type > multisetName; 对于 multimap 或 multiset 一种迭代策略是联合使用由 find()返回的 iterator 指向第一 个实例 和由 count()返回的值 这样做能奏效 因为我们可以保证实例在容器中是连续出 现的 例如 #include #include void code_fragment() { multimap< string, string > authors; string search_item( "Alain de Botton" ); // ... int number = authors.count( search_item ); multimap< string,string >::iterator iter; iter = authors.find( search_item ); for ( int cnt = 0; cnt < number; ++cnt, ++iter ) do_something( *iter ); // ... } 更精彩的策略是使用由 multiset 和 multimap 的特殊操作 equal_range()返回的 iterator 对 值 如果这个值存在 则第一个 iterator 指向该值的第一个实例 且第二个 iterator 指向这个值的 268 第六章 抽象容器类型 最后实例的下一位置 如果最后的实例是 multiset 的末元素 则第二个 iterator 等于 end() 例如 #include #include #include void code_fragment() { multimap< string, string > authors; // ... string search_item( "Haruki Murakami" ); while ( cin && cin >> search_item ) switch ( authors.count( search_item )) { // 不存在, 继续往下走 case 0: break; // 只有一项, 使用普通的 find()操作 case 1: { multimap< string,string >::iterator iter; iter = authors.find( search_item ); // do something with element break; } // 出现多项 ... default: { typedef multimap< string,string >::iterator iterator; pair< iterator, iterator > pos; // pos.first 指向第一个出现 // pos.second 指向值不再出现的位置 pos = authors.equal_range( search_item ); for ( ; pos.first != pos.second; pos.first++ ) // 对每个元素进行操作 } } } 插入和删除操作与关联容器 set 和 map 相同 equal_range()可以用来提供 iterator 对 以 便标记出要被删除的多个元素的范围 例如 #include #include typedef multimap< string, string >::iterator iterator; pair< iterator, iterator > pos; string search_item( "Kazuo Ishiguro" ); // authors 是一个 multimap // 这等价于 authors.erase( search_item ); pos = authors.equal_range( search_item ); 269 第六章 抽象容器类型 authors.erase( pos.first, pos.second ); 每次插入增加一个元素 例如 typedef multimap::value_type valType; multimap authors; // 引入 Barth 下的第一个键 authors.insert( valType( string( "Barth, John" ), string( "Sot-Weed Factor" ))); // 引入 Barth 下的第二个键 authors.insert( valType( string( "Barth, John" ), string( "Lost in the Funhouse" ))); 不支持下标操作是访问 multimap 元素的一个限制 例如 authors[ "Barth, John" ]; // 错误: multimap 将导致编译错误 练习 6.28 用 multimap 重新实现 6.14 节的文本查询程序 multiset 中的每个位置都是独立输入的 两个方案的性能和设计特性各是什么 你认为哪一个更好 为什么 6.16 栈 在 4.5 节中 我们通过栈抽象的实现说明了递增和递减操作符的用法 一般地 在程序 执行过程中可能动态出现多个嵌套状态时 为了维护当前的状态 栈 stack 机制提供了一 种有力的解决方案 因为栈是一种重要的数据抽象 所以标准 C++库提供了相应的类实现 为了使用它 我们必须包含相关的头文件 #include 标准库提供的栈与我们的实现有点不同 在我们的实现中 它把对栈顶元素的访问和 删除分别独立放到 top()和 pop()操作中 栈容器 stack container 支持的全部操作集如下 所示 表格 6.5 栈容器支持的操作 操作 功能 empty() 如果栈为空 则返回 true 否则返回 false size() 返回栈中元素的个数 pop() 删除 但不返回栈顶元素 top() 返回 但不删除栈顶元素 push(item) 放入新的栈顶元素 270 第六章 抽象容器类型 下面的程序练习了这五个栈操作 #include #include int main() { const int ia_size = 10; int ia[ia_size ] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; // 填充 stack int ix = 0; stack< int > intStack; for ( ; ix < ia_size; ++ix ) intStack.push( ia[ ix ] ); int error_cnt = 0; if ( intStack.size() != ia_size ) { cerr << "oops! invalid intStack size: " << intStack.size() << "\t expected: " << ia_size << endl; ++error_cnt; } int value; while ( intStack.empty() == false ) { // 读取栈顶元素 value = intStack.top(); if ( value != --ix ) { cerr << "oops! expected " << ix << " received " << value << endl; ++error_cnt; } // 弹出栈顶元素, 并重复 intStack.pop(); } cout << "Our program ran with " << error_cnt << " errors!" << endl; } 如下声明 stack< int > intStack; 将声明 intStack 为一个整型元素的空栈 栈类型被称为容器适配器 container adapter 因为它把栈抽象施加在底层容器集上 缺省情况下 栈用容器类型 deque 实现 因为 deque 为容器前端的插入和删除提供了有效支持 而 vector 则不 如果我们希望改写这种缺省的实 现 则可以定义一个栈对象 以提供显式的顺序容器类型作为第二个参数 例如 stack< int, list > intStack; 271 第六章 抽象容器类型 栈的元素被按值输入 每个对象被拷贝到底层的容器中 对大型或复杂类对象 这种方 法可能过于昂贵 尤其是在我们只是读取元素的情况下 一种取代的存储策略是定义一个指 针栈 例如 #include class NurbSurface { /* mumble */ }; stack< NurbSurface* > surf_Stack; 同一类型的两个栈可以比较相等 不相等 小于 大于 小于等于以及大于等于关系 只要底层元素类型支持等于和小于操作符即可 对于这些操作 栈中元素被依次比较 第一 对不相等的元素决定了小于或大于关系 我们将在 17.7 节支持复杂的用户文本查询如 Civil && ( War || Rights ) 时说明栈的用法 6.17 队列和优先级队列 队列 queue 抽象体现了先进先出 FIFO 即 first in first out 的存储和检索策略 进入队列的对象被放在尾部 下一个被取出的元素取自队列的首部 标准库提供了两种风格 的队列 FIFO 队列 就称作 queue 以及 priority_queue 优先级队列 priority_queue允许用户为队列中包含的元素项建立优先级 它没有把新元素放在队列尾 部 而是放在比它优先级低的元素前面 定义优先级队列的用户决定怎样确定优先级 在实 践中 优先级队列的一个实例是机场行李检查队列 在 15 分钟后即将离港航班的乘客通常会 被移到队列前面 以便他们能在飞机起飞前完成检查过程 优先级队列的大程序上的例子是 操作系统的调度表 它可以决定在大量等待进程中下一个要执行的进程 要使用这两种队列 必须包含相关的头文件 #include 队列和 priority_queue 支持的全部操作见表 6.6 表 6.6 队列和优先级队列支持的操作 操 作 功 能 empty() 如果队列为空 则返回 true 否则返回 false size() 返回队列中元素的个数 pop() 删除 但不返回队首元素 在 priority_queue 中 队首元素代表优先级最 高的元素 front() 返回 但不删除队首元素 它只能应用在一般队列上 back() 返回 但不删除队尾元素 它只能应用在一般队列上 top() 返回 但不删除 priority_queue 的优先级最高的元素 只能应用在 priority_queue 上 push(item) 在队尾放入一个新元素 对于 priority_queue 将根据优先级排序 272 第六章 抽象容器类型 priority_queue的元素被强加了顺序关系 以便元素可以从大到小管理 这里所谓最大即 等价于拥有最高优先级 缺省情况下 元素的优先级由底层元素类型相关的小于操作符执行 如果希望改写缺省的小于操作符 我们可以显式地提供一个函数或函数对象来排序优先级队 列的元素 12.3 节将进一步解释和说明这种用法 6.18 回顾 iStack 类 4.15 节给出的 iStack 类有两个方面的限制 1 它只支持一种类型 int 型 我们希望支持所有的类型 可以通过对它做些转换 使 它变成一个通用的 Stack 模板类 2 它的长度固定 这在两个方面存在问题 栈可能变满 因而不能使用 为避免栈满 我们为它分配了过大的平均存储空间 解决方案是支持栈的动态增长 我们可以通过直接使 用由底层 vector 对象提供的动态支持来实现它 在开始之前 先给出 Stack 的原始定义 #include class iStack { public: iStack( int capacity ) : _stack( capacity ), _top( 0 ) {} bool pop( int &value ); bool push( int value ); bool full(); bool empty(); void display(); int size(); private: int _top; vector< int > _stack; }; 让我们先把它转换成支持动态分配的类 这意味着 我们必须插入和删除元素 而不是 索引固定长度的 vector 即不再需要数据成员_top 通过 push_back()和 pop_back()就可以自 动管理栈顶元素 下面是修改后的 pop()和 push()实现 bool iStack::pop( int &top_value ) { if ( empty() ) return false; top_value = _stack.back(); _stack.pop_back(); return true; } bool iStack::push( int value ) { 273 第六章 抽象容器类型 if ( full() ) return false; _stack.push_back( value ); return true; } empty() size()以及 full()也必须重新实现——在这个版本中 它们与底层 vector 的耦合 更加紧密 inline bool iStack::empty(){ return _stack.empty(); } inline int iStack::size() { return _stack.size(); } inline bool iStack::full() { return _stack.max_size() == _stack.size(); } display()需要稍作修改 去掉_top 作为 for 循环结束条件的用法 void iStack::display() { cout << "( " << size() << " )( bot: "; for ( int ix = 0; ix < size(); ++ix ) cout << _stack[ ix ] << " "; cout << " :top )\n"; } 惟一比较重要的变化就是必须修改 iStack 的构造函数 严格来说 我们的构造函数不再 需要做任何事情 对于重新实现的 iStack 类 下面的空构造函数已经足够了 inline iStack::iStack() {} 但是 对用户来说这是不够的 因此 我们完全保留了原来的接口 这样 现有的用户 代码就不需要重写 必须维持一个单参数的构造函数 尽管我们不再像原来的版本那样需要 这个参数 修改后的接口接收一个 int 参数 但是实际上并不需要这个参数 class iStack { public: iStack( int capacity = 0 ); // ... }; 如果出现了这个参数 该怎么办呢 我们会用它设置 vector 的容量 inline iStack::iStack( int capacity ) { if ( capacity ) _stack.reserve( capacity ); } 从非模板向模板类的转换相当简单 部分原因是由于底层 vector 对象已经属于类模板 下面是修改后的类定义 #include template class Stack { public: 274 第六章 抽象容器类型 Stack( int capacity=0 ); bool push( elemType value ); bool full(); bool empty(); void display(); int size(); private: vector< elemType > _stack; }; 为了保持与使用早期 iStack 类实现的已有程序的兼容性 我们提供下列 typedef typedef Stack iStack; 成员操作的修改留作练习 练习 6.29 为动态 Stack 类模板重新实现 peek()函数 4.15 节的练习 4.23 练习 6.30 为 Stack 类模板提供修改后的成员操作 运行 4.15 节的测试程序测试新的实现 练习 6.31 利用 5.11.1 节的 List 类的模型 把我们的 Stack 类模板封装到 Primer_Third_Edtion 名字 空间中 第三篇 基于过程的 程序设计 第二篇介绍了 C++程序设计语言的基本要素 内置数据类型 如 int 和 double 类抽 象类型 如 string 和 vector 以及在这些类型上执行的操作 在第三篇中 我们将看到这 些基本程序要素怎样组成函数定义 函数主要用来实现各种算法 由这些算法执行程序中特 定的任务 对于每个 C++程序 我们都必须定义一个称作 main()的函数 它是 C++程序开始执行时 第一个调用的函数 main()函数再调用其他函数来完成程序所要求的任务 程序中的函数通 信 或称信息交换 都是通过函数接收的值 称为参数 parameter 以及函数返回的值来完 成的 第 7 章将介绍 C++的函数机制 函数可用来把程序组织成小的 独立的单元 每个函数封装一个或者一组算法 这些算 法又分别应用在特定的数据集上 我们可以声明对象和类型 以让它们在整个程序中使用 但是 如果这些对象或类型只在程序的一个子集中被使用 那么 比较好的做法是将其限制 在被访问的范围内 并把它们的声明与用到它们的函数相关联 域 scope 是一种机制 它 可以由程序员用来限制程序中声明的可视性 在第 8 章中 我们将介绍 C++支持的不同的域 我们还将了解域怎样影响声明的可视性 以及 C++对象的生命期和运行时刻属性 C++为简化程序中函数的用法提供了许多设施 在本篇中 我们将依次回顾这些设施 第一个设施是重载函数 overloaded function 它提供了公共的 但应用于不同数据类型上 的操作 这样实现不同的函数可以共享同一个名字 例如 输出值类型不同的函数 比如整 型 字符串等等 都可以被命名为 print() 这简化了函数的用法 因为程序员不必为相同的 操作而记住不同的函数名 编译器根据函数参数的类型选择要调用的相应函数 第 9 章将讨 论怎样声明和使用重载函数 以及对于给定的函数调用 编译器将怎样在一组重载函数中选 择 C++支持的简化函数用法的第二种设施是函数模板 function template 函数模板是一 个通用的 generic 函数定义 用来自动生成一组无限多的函数定义 它们类型不同 但具 体实现维持不变数 第 10 章将描述怎样定义函数模板 以及怎样用它生成或实例化函数定义 程序的函数通过接收值 又称参数 以及返回值来通信 但是 在程序执行时遇到特殊 情况或程序出现异常状态时 这种机制就不够用了 这样的情况称作异常 exception 需 要立即给予注意 井要求函数马上与调用函数通信 告知出现了异常 C++提供了异常处理 276 第三篇 基于过程的程序设计 设施 允许在这些非正常情况下函数之间的通信 异常处理将在第 11 章中讲述 最后 C++标准库还提供了一个称为泛型算法 generic algrithm 的常用函数扩展集 第 12 章将描述 C++标准库提供的泛型算法 并探讨它们是怎样与第 6 章中的容器类型以及内置 数组类型进行交互的 7 函 数 现在我们已经知道怎样声明变量 第3章 怎样写表达式 第4章 和语句 第 5章 本章我们将了解怎样把它们组织到函数定义中 以便在程序中能够重用这 些语言要素 本章将描述怎样声明和定义函数 以及怎样在程序中调用它们 本章 将给出函数可以接受的不同类型的参数 以及各种类型参数的属性 此外 还将给 出不同类型的返回值 然后我们再分析四种特殊的函数类型 内联 inline 函数 递归函数 用链接指示符 linkage directive 声明的非 C++函数 以及 main()函 数 最后 用一个更高级的话题——函数指针来结束本章 7.1 概述 函数可以被看作是一个由用户定义的操作 一般来说 函数由一个名字来表示 函数的 操作数 称为参数 parameter 由一个位于括号中 并且用逗号分隔的参数表 parameter list 指定 函数的结果被称为返回值 return value 返问值的类型被称为函数返回类型 return type 不产生值的函数 返回类型是 void 意思是什么都不返回 函数执行的动作在函数 体 body 中指定 函数体包含在花括号中 有时也称为函数块 function block 函数返 回类型 以及其后的函数名 参数表和函数体构成了函数定义 下面是函数定义的一些例子 inline int abs( int iobj ) { // 返回 iobj 的绝对值 return( iobj < 0 ? -iobj : iobj ); } inline int min( int p1, int p2 ) { // 返回较小值 return( p1 < p2 ? p1 : p2 ); } int gcd( int v1, int v2 ) { // 返回最大公约数 while ( v2 ) 278 第七章 函数 { int temp = v2; v2 = v1 % v2; v1 = temp; } return v1; } 当函数名后面紧跟着调用操作符 () 时 这个函数就被执行了 如果函数被定义为应 该接收参数 则在调用这个函数时 就需要为这些参数提供实参 argument 且这些实参 被放在调用操作符中 而两个相邻的实参用逗号分隔 这种安排称为 向函数传递参数 passing argument 在下面的例子中 main()调用了 abs()两次 min()和 gcd()各一次 main()被定义在文件 main.C 中 #include int main() { // get values from standard input cout << "Enter first value: "; int i; cin >> i; if ( !cin ) { cerr << "!<< Oops: input error - Bailing out!\n"; return -1; } cout << "Enter second value: "; int j; cin >> j; if ( !cin ) { cerr << "!<< Oops: input error - Bailing out!\n"; return -2; } cout << "\nmin: " << min( i, j ) << endl; i = abs( i ); j = abs( j ); cout << "gcd: " << gcd( i, j ) << endl; return 0; } 函数调用会导致两件事情发生 如果函数已经被声明为 inline 内联 则函数体可能 已经在编译期间它的调用点上就被展开 如果没有被声明为 inline 则函数在运行时才被调 用 函数调用会使程序控制权被传送给正在被调用的函数 而当前活动函数的执行被挂起 当被调用的函数完成时 主调函数在调用语句之后的语句上恢复执行 函数在执行完函数体 的最后一条语句或遇到返回语句 return statement 后完成 我们必须在调用函数之前就声明该函数 否则会引起编译错误 当然 函数定义也可以 被用作声明 但是 函数在程序中只能被定义一次 典型情况下 函数定义被放在单独的程 序文本文件中 或者与其他相关的函数定义放在同一个文本文件中 要想在其他文件而不是 包含函数定义的文件中使用该函数 我们必须要用到另外一种函数声明机制 279 第七章 函数 函数声明由函数返回类型 函数名和参数表构成 这三个元素被称为函数声明 function declaration 或函数原型 function prototype 一个函数可在一个文件中被声明多次 在我们的 main.C 例子中 如果在 main()之前没有定义函数 abs() min()和 gcd() 那么在 main()中对它们的调用将导致编译错误 然而 要使 main.C 无编译错误 并不要求我们一定 在 main()之前定义它们 只需如下声明 函数声明不需指定参数的名字 只需要每个参数的 类型 int abs( int ); int min( int, int ); int gcd( int, int ); 函数声明 以及 inline 函数的定义 最好放在头文件中 这些头文件可以被包含 include 在每个调用该函数的文件中 通过这种方式 所有文件共享一个公共的声明 如果需要修改 此声明 则只有这一个实例需要被改变 程序的头文件可如下定义 我们把它称做 localMath.h // gcd.C 中的定义 int gcd( int, int ); inline int abs(int i) { return( i<0 ? -i : i ); } inline int min(int v1,int v2) { return( v1 #include class Date { /* 定义 */ }; bool look_up( int *, int ); double calc( double ); int count( const string &, char ); Date& calendar( const char* ); void sum( vector&, int ); 函数类型和内置数组类型不能用作返同类型 例如 下面列举了一个这样的错误 // 非法: 数组不能作返回类型 int[10] foo_bar(); 我们必须返回一个指向数组中元素类型的指针 // ok: 指向数组的第一个元素的指针 int *foo_bar(); 指向数组第一个元素的指针被返回 如何得到数组的长度 那是处理返回值的用户要 负责的事了 但是 类类型和容器类型可以被直接返回 例如 // ok: 返回类型是 char 的 list list foo_bar(); 但是 这种方式效率比较低 按值返回的讨论见 7.4 节 函数必须指定一个返回值 没有显式返回值的声明或者定义将引起编译错误 例如 // 错误: 没有返回类型 const is_equal( vector v1, vector v2 ); 在 C++标准化之前 如果缺少显式返回类型的话 返回值会被假定为 int 类型 在标准 C++中 返回类型不能被省略 is_equal()的正确声明是 // ok: 返回类型已被指定 const bool is_equal( vector v1, vector v2 ); 7.2.2 函数参数表 函数的参数表不能省略 没有任何参数的函数可以用空参数表或含有单个关键字 void 的 参数表来表示 例如 下面有关 fork()的两个声明是等价的 int fork(); // 隐式的 void 参数表 int fork( void ); // 等价声明 参数表由逗号分隔的参数类型列表构成 每个参数类型之后可以跟一个名字 它是可选 的 参数表中使用逗号分隔的简写方式 即在声明中使用的方式 是错误的 例如 int manip( int v1, v2 ); // 错误 int manip( int v1, int v2 ); // ok 281 第七章 函数 参数表中不能出现同名的参数 函数定义的参数表中的参数名允许在函数体中访问这个 参数 函数声明中的参数名不是必需的 如果名字存在的话 它应该被用作辅助文档 例如 void print( int *array, int size ); 为同一函数的声明和定义中的参数指定不同的名字 在语言上没有错误 但是 程序的 读者可能会被弄糊涂 在 C++中 两个函数可能同名但参数表不同 这种函数被称为重载函数 overloaded function 参数表称为函数的符号特征 signature 冈为它被用来区分函数的不同实例 有了名字和符号特征就可以惟一地标识函数了 第 9 章将更完整地讨论重载函数 7.2.3 参数类型检查 函数 gcd()的声明如下 int gcd( int, int ); 这个声明指出函数有两个 int 型的参数 函数的参数表为编译器提供了必需的信息 使 它能够在函数调用时对给出的实参进行类型检查 例如 如果实参是 const char* 会发生什 么情况呢 比如 下面调用的结果是什么 gcd( "hello", "world" ); 或者 如果向函数 gcd()传递了一个或多于两个的实参 又会发生什么情况 如果不小心 将 24 和 312 连接在一起 又会怎么样 gcd( 24312 ); 在编译 gcd()的后两个调用时 惟一期望的结果就是编译错误 任何希望执行该调用的企 图都会导致灾难性的后果 在 C++中 这两个调用将导致如下形式的编译错误信息 // gcd( "hello", "world" ) error: invalid argument types ( const char*, const char* ) -- expecting ( int, int ) // gcd( 24312 ) error: missing value for second argument 如果两个参数都是 double 型 又会怎么样呢 该调用应该被标记为错误吗 gcd( 3.14, 6.29 ); 正如 4.14 节中所示 double 型的值可以被转换成 int 型的值 因此 把该调用标记为错 误有些过于严格 参数被隐式地转换成 int 通过截取 就能满足参数表的类型要求 但是 因为这是可能带有精度损失的窄化转换 编译器一般都会产生一个警告 调用变为 gcd( 3, 6 ); 返回值是 3 C++是一种强类型 strong typed 语言 每个函数调用的实参在编译期间都要经过类型 检查 type-checked 若实参类型与相应的参数类型不匹配 如果有可能 就会应用一个隐 式的类型转换 如上个例子中从 double 到 int 的转换 如果不可能进行隐式转换或者实参的 282 第七章 函数 个数不正确 就会产生一个编译错误 这就是函数必须先被声明才能被使用的原因 编译器 必须根据函数参数表 对函数凋用的实参执行类型检查 就此而言 声明是必不可少的 省略实参或者传递类型错误的实参都是 C 语言标准化之前严重运行时刻错误的根源 由 于 C++引入了强类型检查 这些接口错误都将在编译时被捕捉到 练习 7.1 下列哪些函数原型是无效的 为什么 (a) set( int *, int ); (b) void func(); (c) string error( int ); (d) arr[10] sum( int *, int ); 练习 7.2 请为下列函数写出函数原型 a 函数名为 compare 有两个参数 它们是名为 matrix 的类的引用 返回类型为 bool b 函数名为 extract 没有参数 返回值为整型 set 这里的 set 是 6.13 节定义的容器 类型 练习 7.3 已知下列声明 哪些函数调用是错误的 为什么 double calc( double ); int count( const string &, char ); void sum( vector &, int ); vector vec( 10 ); (a) calc( 23.4, 55.1 ); (b) count( "abcda", 'a' ); (c) sum( vec, 43.8 ); (d) calc( 66 ); 7.3 参数传递 所有的函数都使用在程序运行栈 run-time stack 中分配的存储区 该存储区一直保持 与该函数相关联 直到函数结束为止 那时 存储区将自动释放以便重新使用 该函数的整 个存储区被称为活动记录 activation record 系统在函数的活动记录中为函数的每个参数都提供了存储区 参数的存储长度由它的类 型来决定 参数传递是指用函数调用的实参值来初始化函数参数存储区的过程 C++中参数传递的缺省初始化方法是把实参的值拷贝到参数的存储区中 这被称为按值 传递 pass-by-value 按值传递时 函数不会访问当前调用的实参 函数处理的值是它本地的拷贝 这些拷贝 被存储在运行栈中 因此改变这些值不会影响实参的值 一旦函数结束了 函数的活动记录 283 第七章 函数 将从栈中弹出 这些局部值也就消失了 在按值传递的情况下 实参的内容没有被改变 这意味着程序员在函数调用时无需保存 和恢复实参的值 如果没有按值传递机制 那么每个没有被声明为 const 的参数就可能会随 每次函数调用而被改变 按值传递的危害最小 需要用户做的工作也最少 毫无疑问 按值 传递是参数传递合理的缺省机制 但是 按值传递并不是在所有的情况下都适合 不适合的情况包括 当大型的类对象必须作为参数传递时 对实际的应用程序而言 分配对象并拷贝到 栈中的时间和空间开销往往过大 当实参的值必须被修改时 例如 在函数 swap()中 用户想改变实参的值 但是在 按值传递的情况下无法做到 // swap() 没有交换两个实参的值! void swap( int v1, int v2 ) { int tmp = v2; v2 = v1; v1 = tmp; } swap()交换实参的本地拷贝 代表 swap()实参的变量并没有被改变 这将在下面调用 swap()的程序中可以看出来 #include void swap( int, int ); 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()可重写如下 // pswap()交换 v1 和 v2 指向的值 void pswap( int *v1, int *v2 ) { int tmp = *v2; *v2 = *v1; *v1 = tmp; } 284 第七章 函数 我们必须修改 main()来调用 pswap() 现在程序员必须传递两个对象的地址而不是对象本 身 pswap( &i, &j ); 修改后的程序编译运行后的结果显示了它的正确性 // 使用指针使程序员能够访问当前调用的实参 Before swap(): i: 10 j: 20 After swap(): i: 20 j: 10 第二种方法是把参数声明成引用 例如 swap()可重写如下 // rswap() 交换 v1 和 v2 引用的值 void rswap( int &v1, int &v2 ) { int tmp = v2; v2 = v1; v1 = tmp; } main()中 rswap()的调用看起来像原来的 swap()调用 rswap( i, j ); 编译并运行这程序会显示 i 和 j 的值已经被正确交换了 7.3.1 引用参数 把参数声明成引用 实际上改变了缺省的按值传递参数的传递机制 在按值传递时 函 数操纵的是实参的本地拷贝 当参数是引用时 函数接收的是实参的左值而不是值的拷贝 这意味着函数知道实参在内存中的位置 因而能够改变它的值或取它的地址 什么时候将一个参数指定为引用比较合适呢 像 swap()的情况 它必须将一个参数改变 成指针来允许改变实参的值时就比较合适 引用参数的第二种普遍用法是向主调函数返回额 外的结果 第三种用法是向函数传递大型类对象 我们将更详细地查看后两种情况 作为 通过引用参数向主调函数返回额外结果 的函数的一个例子 我们来定义一个被 称为 look_up()的函数 它在整型 vector 中查找一个特定的值 如果找到了该值 则 look_up() 返回一个指向含有该值的 vector 元素的 iterator 迭代器 否则 返问一个指向 vector 最后 一个元素下一位置的 iterator 表明该值不存在 在多次出现的情况下 指向第一次出现的 iterator 被返回 此外 look_up()用引用参数 occurs 返回该值出现的次数 #include // 引用参数 'occurs' 可以含有第二个返回值 vector::const_iterator look_up( const vector &vec, int value, // 值在 vector 中吗? int &occurs ) // 多少次? { // res_iter 被初始化为最后一个元素的下一位置 vector::const_iterator res_iter = vec.end(); occurs = 0; 285 第七章 函数 for ( vector::const_iterator iter = vec.begin(); iter != vec.end(); ++iter ) if ( *iter == value ) { if ( res_iter == vec.end() ) res_iter = iter; ++occurs; } return res_iter; } 把一个参数声明成引用的第三种情况是在向函数传递一个大型类对象时 在按值传递情 况下 整个对象将随每次调用而被拷贝 尽管按值传递对内置数据类型的对象和小型类对象 比较满意 但是对于大型类对象 它的效率就太低了 使用引用参数 函数可以访问被指定 为实参的类对象 而不必在函数的活动记录中拷贝它 例如 class Huge { public: double stuff[1000]; }; extern int calc( const Huge & ); int main() { Huge table[ 1000 ]; // ... 初始化 table int sum = 0; for ( int ix=0; ix < 1000; ++ix ) // 函数 calc() 将指向 Huge 类型的数组元素指定为实参 sum += calc( table[ix] ); // ... } 有人可能希望用引用参数以避免拷贝用作实参的大型类对象 同时 又希望防止函数修 改实参的值 如果引用参数不希望在被调用的函数内部被修改 那么把参数声明为 const 型 的引用是个不错的办法 这种方式能够使编译器防止无意的改变 例如 下列程序段违反了 foo()的参数 xx 的常量性 因为 foo_bar()的参数不是 const 型的引用 所以我们不能保证 foo_bar()不会改变参数 xx 的值 这违反了 foo()的参数 xx 的常量性 程序被编译器标记为错 误 class X; extern int foo_bar( X& ); int foo( const X& xx ) { // 错误: const 传递给非 const return foo_bar( xx ); } 为使该程序通过编译 我们改变 foo_bar()的参数的类型 以下两种声明都是可以接受的 extern int foo_bar( const X& ); extern int foo_bar( X ); // 按值传递 286 第七章 函数 或者可以传递一个 xx 的拷贝做实参 允许 foo_bar()改变它 int foo( const X &xx ) { // ... X x2 = xx; // 拷贝值 // 当 foo_bar() 改变它的引用参数时, x2 被改变, xx 保持不变 return foo_bar( x2 ); // ok } 我们可以声明任意内置数据类型的引用参数 例如 如果程序员想修改指针本身 而不 是指针引用的对象 那么他可以声明一个参数 该参数是一个指针的引用 例如 下面是交 换两个指针的函数 void ptrswap( int *&v1, int *&v2 ) { int *tmp = v2; v2 = v1; v1 = tmp; } 如下声明 int *&v1; 应该从右向左读 v1 是一个引用 它引用一个指针 指针指向 int 型的对象 用函数 main() 操纵函数 rswap() 我们可以如下修改代码以便交换两个指针值 #include void ptrswap( int *&v1, int *&v2 ); int main() { int i = 10; int j = 20; int *pi = &i; int *pj = &j; cout << "Before ptrswap():\tpi: " << *pi << "\tpj: " << *pj << endl; ptrswap( pi, pj ); cout << "After ptrswap():\tpi: " << *pi << "\tpj: " << *pj << endl; return 0; } 编译并运行程序 产生下列输出 Before ptrswap(): pi: 10 pj: 20 After ptrswap(): pi: 20 pj: 10 7.3.2 引用和指针参数的关系 现在你或许想知道 应该将一个函数声明成引用还是指针呢 确实 这两种参数都允许 函数修改实参指向的对象 两种类型的参数都允许有效地向函数传递大型类对象 所以 怎 287 第七章 函数 样决定把函数参数声明成引用还是指针呢 正如 3.6 节所提到的 引用必须被初始化为指向一个对象 一旦初始化了 它就不能再 指向其他对象 指针可以指向一系列不同的对象也可以什么都不指向 因为指针可能指向一个对象或没有任何对象 所以函数在确定指针实际指向一个有效的 对象之前不能安全地解引用 dereference 一个指针 例如 class X; void manip( X *px ) { // 在解引用指针之前确信它非 0 if ( px != 0 ) // 解引用指针 } 另一方面 对于引用参数 函数不需要保证它指向一个对象 引用必须指向一个对象 甚至在我们不希望这样时也是如此 例如 class Type { }; void operate( const Type& p1, const Type& p2 ); int main() { Type obj1; // 设置 obj1 为某个值 // 错误: 引用参数的实参不能为 0 Type obj2 = operate( obj1, 0 ); } 如果一个参数可能在函数中指向不同的对象 或者这个参数可能不指向任何对象 则必 须使用指针参数 引用参数的一个重要用法是 它允许我们在有效地实现重载操作符的同时 还能保证用 法的直观性 重载操作符的完整讨论见第 15 章 让我们从下面的例子开始 它使用了 Matrix 类类型 我们想支持两个 Matrix 类对象的加法和赋值操作符 使它们的用法同内置类型一样 自然 Matrix a, b, c; c = a + b; Matrix类对象的加法和赋值操作符用重载操作符来实现 被重载的操作符是一个带有特 殊名字的函数 在加法操作符的例子中 函数名是 operator+ 让我们为这个重载操作符提供 一个定义 Matrix // 加法返回一个 Matrix 对象 operator+( // 重载操作符的名字 Matrix m1, // 操作符左操作数的类型 Matrix m2 // 操作符右操作数的类型 ) { Matrix result; // do the computation in result return result; } 288 第七章 函数 该实现支持两个 Matrix 对象的加法 如 a + b 但不幸的是 它的效率低得让人难以接受 注意 operator+()的参数不是引用 这意味 着 operator+()的实参是按值传递的 两个 Matrix 对象 a 和 b 的内容被拷贝到 opertor+()函数 的参数区中 因为 Matrix 类对象非常大 分配这样一个对象并把它拷贝到函数参数区中的时 间和空间开销高得让人难以接受 为了提高我们的操作符函数的效率 假定我们决定把参数声明为指针 下面是对 operator+()新的实现代码 // 使用指针参数重新实现 Matrix operator+( Matrix *m1, Matrix *m2 ) { Matrix result; // 在 result 中计算 return result; } 但是 这个实现代码有这样的问题 虽然我们获得了效率 但是 它是以放弃加法操作 符用法的直观性为代价的 现在指针参数要求我们传递地址作为实参 它们指向我们希望做 加法操作的 Matrix 对象 现在 我们的加法操作必须如下编程 &a + &b; // 不太好, 但也不是不可能 但是 这比较难看 而且可能引起一些程序员抱怨用户接口不友好 在一个复合表达式 中加三个对象变得很困难 // 喔! 这无法工作 // &a + &b 的返回类型是 Matrix 对象 &a + &b + &c; 为了使在指针方案下三个对象的加法能够很好地实现 程序必须这样写 // ok: 这样能行, 但是 &( &a + &b ) + &c; 当然 没有人希望那样写 引用参数提供了我们需要的方案 当参数是引用时 函数接 收到的是实参的左值而不是值的拷贝 因为函数知道实参在内存的什么位置 所以实参值没 有被拷贝到函数的参数区 引用参数的实参是 Matrix 对象本身 这允许我们像对内置数据类 型的对象使用加法操作符一样自然地使用加法操作符 下面是 Matrix 类的重载加法操作符的修订版本 // 使用引用参数的新实现 Matrix operator+( const Matrix &m1, const Matrix &m2 ) { Matrix result; // 在 result 中进行计算 return result; } 该实现支持如下形式的 Matrix 对象的加法 a + b + c 289 第七章 函数 为了支持类 class 类型——尤其是支持有效直观地实现重载操作符机制 C++特别引 入了引用机制 7.3.3 数组参数 在 C++中 数组永远不会按值传递 它是传递第一个元素 准确地说是第 0 个 的指针 例如 如下声明 void putValues( int[ 10 ] ); 被编译器视为 void putValues( int* ); 数组的长度与参数声明无关 因此 下列三个声明是等价的 // 三个等价的 putValues()声明 void putValues( int* ); void putValues( int[] ); void putValues( int[ 10 ] ); 因为数组被传递为指针 所以这对程序员有两个含义 在被调函数内对参数数组的改变将被应用到数组实参上而不是本地拷贝上 当用作 实参的数组必须保持不变时 程序员需要保留原始数组的拷贝 函数可以通过把参 数类型声明为 const 来表明不希望改变数组元素 void putValues( const int[ 10 ] ); 数组长度不是参数类型的一部分 函数不知道传递给它的数组的实际长度 编泽器 也不知道 当编译器对实参类型进行参数类型检查时 并不检查数组的长度 例如 void putValues( int[ 10 ] ); // 视为 int* int main() { int i, j[ 2 ]; putValues( &i ); // ok: &i 是 int*; 潜在的运行错误 putValues( j ); // ok: j 被转换成第 0 个元素的指针 // 实参类型为 int*: 潜在的运行错误 return 0; } 参数的类型检查只能保证 putValues()的两次调用都提供了 int*型的实参 类型检查不能 检验实参是一个 10 元素的数组 习惯上 C 风格字符串是字符的数组 它用一个空字符编码作为结尾 但是所有其他类 型 包括希望处理内含空字符的字符数组 必须以某种方式在向函数传递实参时使其知道它 的长度 一种常见的机制是提供一个含有数组长度的额外参数 例如 void putValues( int[], int size ); int main() { int i, j[ 2 ]; putValues( &i, 1 ); putValues( j, 2 ); return 0; } 290 第七章 函数 putValues()以下列格式输出数组的值 ( 10 ) < 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 > 这里 10 代表数组的长度 下面的实现代码用一个额外的参数表示数组的长度 #include const lineLength = 12; // 一行中的元素数 void putValues( int *ia, int sz ) { cout << "( " << sz << " )< "; for ( int i = 0; i < sz; ++i ) { if ( i % lineLength == 0 && i ) cout << "\n\t"; // 一行满了 cout << ia[ i ]; // 用逗号分隔元素 if ( i % lineLength != lineLength-1 && i != sz-1 ) cout << ", "; } cout << " >\n"; } 另外一种机制是将参数声明为数组的引用 当参数是一个数组类型的引用时 数组长度 成为参数和实参类型的一部分 编译器检查数组实参的长度与在函数参数类型中指定的长度 是否匹配 // 参数为 10 个 int 的数组 // parameter is a reference to an array of 10 ints void putValues( int (&arr)[10] ); int main() { int i, j[ 2 ]; putValues( i ); // 错误: 实参不是 10 个 int 的数组 putValues( j ); // 错误: 实参不是 10 个 int 的数组 return 0; } 因为数组的长度现在是参数类型的一部分 所以 putValues()的这个版本只接受 10 个 int 的数组 这限制了可以作为实参被传递给 putValues()的数组的种类 但是 它也使函数的实 现更加简单 #include void putValues( int (&ia)[10] ) { cout << "( 10 )< "; for ( int i = 0; i < 10; ++i ) { cout << ia[ i ]; // 用逗号分隔元素 if ( i != 9 ) cout << ", "; } 291 第七章 函数 cout << " >\n"; } 还有另外一种机制是使用抽象容器类型 抽象容器类型由第 6 章引入 这种机制将在 下一小节进一步介绍 虽然前两个 putValues()的实现版本也能生效 但是它们有许多严重的限制 第一个实现 只在 int 型数组时奏效 我们需要有第二个函数处理 double 型数组 第三个处理 long 型 等 等 第二个实现只在数组是 10 个 int 型元素的数组时才能工作 处理不同类型的数组需要另 外的函数 putValues()的一个较好的实现是把它定义为函数模板 函数模板是一种 其代码 在广泛的不同参数类型上保持不变 的机制 下面给出怎样重写 putValues()的第一个实现 使其作为函数模板处理不同类型和长度的数组 template void putValues( Type *ia, int sz ) { // 同前 } 模板参数被放在一对尖括号中 在本例中 惟一的一个模板参数是 Type 关键字 class 表示模板参数代表一个类型 标识符 Type 用作参数名 在 putValues()的参数表中出现的 Type 用作实例化函数模板的实际类型的占位符 在每次实例化中 实例化的实际类型——int double string 等等——替代 Type 参数 我们将在第 10 章进一步介绍函数模板 参数也可以是多维数组 这样的参数必须指明第一维以外的所有维的长度 例如 void putValues( int matrix[][1a], int rowSize ); 把 matrix 声明成一个二维数组 每行由 10 个列元素构成 matrix 可以被等价地声明为 int (*matrix)[10] 多雏数组被传递为指向其第 0 个元素的指针 在我们的例子中 matrix 的类型是指向 10 个 int 的数组的指针 如同只有一维的数组参数一样 多维数组的第一维与参数类型无关 多维数组的参数类型检查只检验多维数组实参中除了第一维之外的所有维的长度与参数的是 否相同 注意 *matrix 周围的括号是必需的 因为下标操作符的优先级较高 下列声明 int *matrix[ 10 ]; 将 matrix 声明成一个含有 10 个指向 int 的指针的数组 7.3.4 抽象容器类型参数 第 6 章介绍的抽象容器类型也可以被用来声明函数参数 例如 我们可以用 vector 型的参数代替内置数组类型定义 putValues() 容器类型实际上是类类型 它比内置数组数据类型提供了更多的功能 例如 vector 型的参数知道它包含的元素的个数 在上一小节 我们已经知道 如果函数有一个数组参数 且函数并不知道数组的第一维 我们有必要定义另外一个参数来告诉函数数组的长度 而使 用 vector型的参数使我们避开了这个约束 例如 我们可以按如下方式修改函数 putValues()的定义 292 第七章 函数 #include #include const lineLength = 12; // 一行中的元素数 void putValues( vector vec ) { cout << "( " << vec.size() << " )< "; for ( int i = 0; i < vec.size(); ++i ) { if ( i % lineLength == 0 && i ) cout << "\n\t"; // 一行满了 cout << vec[ i ]; // 用逗号分隔元素 if ( i % lineLength != lineLength-1 && i != vec.size()-1 ) cout << ", "; } cout << " >\n"; } main()函数调用新函数 putValues()如下 void putValues( vector ); int main() { int i, j[ 2 ]; // 给 i 和 j 赋值 vector vec1(1); // 创建一个单元素的 vector vec1[0] = i; putValues( vec1 ); vector vec2; // 创建一个空的 vector // 在 vec2 中添加元素 for ( int ix = 0; ix < sizeof( j ) / sizeof( j[0] ); ++ix ) // vec2[ix] == j[ix] vec2.push_back( j[ix] ); putValues( vec2 ); return 0; } 我们注意到 putValues()的参数是值参 即按值传递的参数 当容器类型的参数按值 传递时 容器以及全部元素都被拷贝到被调函数的本地拷贝中 因为拷贝的效率非常低 所 以把容器类型的参数声明为引用参数比较好 你会怎样改变 putValues()的参数声明呢 记住 当一个函数不会修改参数的值时 我们把参数声明为 const 类型的引用更为合适 因而 putValues()的引用参数应该被声明如下 void putValues( const vector & ) { ... 293 第七章 函数 7.3.5 缺省实参 缺省实参是一种虽然并不普遍 但在多数情况下仍然适用的实参值 缺省实参使程序员 从函数接口的每个小细节中解脱出来 函数可以用参数表中的初始化语法为一个或多个参数指定缺省实参 例如 假设一个函 数创建并初始化一个二维字符数组以便模拟终端显示器 则我们可以为屏幕的高 宽和背景 字符提供缺省实参 char *screenInit( int height = 24, int width = 80, char background = ' ' ); 调用包含缺省实参的函数时 我们可以 也可以不 为该参数提供实参 如果提供了实 参 则它将覆盖缺省的实参值 否则 函数将使用缺省实参值 下面的 screenInit()调用都是 正确的 char *cursor; // 等价于 screenInit(24,80, ' ') cursor = screenInit(); // 等价于 screenInit(66, 88, ' ') cursor = screenInit(66); // 等价于 screenInit(66, 256, ' ') cursor = screenInit(66, 256); cursor = screenInit(66, 256, '#'); 函数调用的实参按位置解析 缺省实参只能用来替换函数调用缺少的尾部 tailing 实 参 例如 我们不可能为 background 提供字符值作为实参而不为 height 和 width 提供实参 // 等价于 screenInit('<<', 80, ' ') cursor = screenInit('<<"); // 错误, 不等价于 screenInit(24,80,'<<') cursor = screenInit( , , '<<'); 设计带有缺省实参函数的部分工作就是排列参数表中的参数 使最可能取用户指定值的 参数先出现 而最可能使用缺省实参的参数出现在后面 在 screenInit()的设计假设中 可能 是通过经验得出 height 最可能由用户来提供 函数声明可以为全部或部分参数指定缺省实参 在左边参数的任何缺省实参被提供之 前 最右边未初始化参数必须被提供缺省实参 这是由于函数调用的实参是按照位置来解析 的 // 错误: 在指定 height 之前, width 必须有一个缺省实参 char *screenInit( int height = 24, int width, char background = ' ' ); 一个参数只能在一个文件中被指定一次缺省实参 例如 下列语句是错误的 // ff.h 294 第七章 函数 int ff( int = 0 ); // ff.C #include "ff.h" int ff( int i = 0 ) { ... } // error 习惯上 缺省实参在公共头文件包含的函数声明中指定 而不是在函数定义中 如果缺 省实参在函数定义的参数表中提供 则缺省实参只能用在包含该函数定义的文本文件的函数 调用中 函数后继的声明中可以指定其他缺省实参——一种对特定应用定制通用函数的有用方 法 UNIX 系统函数 chmod()改变文件的保护级别 它的函数声明在系统头文件中 声明如下 int chmod( char *filePath, int protMode ); protMode 表示文件保护模式 filePath 表示文件名字和路径位置 如果一个特殊的应用 总是将文件的保护模式改变成只读模式 read-only 那么我们不用每次都指明它 可以重 新声明 chmod()缺省地提供该值 #include int chmod( char *filepath, int protMode=0444 ); 已知下列在头文件 ff.h 中声明的函数声明 int ff( int a, int b, int c = 0 ); // ff.h 怎样重新声明 ff() 来把缺省实参提供给 b 下列语句是错误的 因为它重新指定了 c 的 缺省实参 #include "ff.h" int ff( int a, int b = 0, int c = 0 ); // 错误 下列看起来错误的重新声明实际上是正确的 #include "ff.h" int ff( int a, int b = 0, int c ); // ok 在 ff()的重新声明中 b 是没有缺省实参的最右边参数 因此 缺省实参必须从最右边位 置开始赋值的规则没有被打破 实际上 我们可以再次声明 ff()为 #include "ff.h" int ff( int a, int b = 0, int c ); // ok int ff( int a = 0, int b, int c ); // ok 缺省实参不一定必须是常量表达式 可以使用任意表达式 例如 int aDefault(); int bDefault( int ); int cDefault( double = 7.8 ); int glob; int ff( int a = aDefault(), int b = bDefault( glob ), 295 第七章 函数 int c = cDefault() ); 当缺省实参是一个表达式时 在函数被调用时该表达式被求值 例如 每次不带第三个 实参调用 ff()时 编译器都会调用 cDefault()为 c 获取一个值 7.3.6 省略号 ellipsis 有时候我们无法列出传递给函数的所有实参的类型和数目 在这种情况下 我们可以用 省略号 ... 指定函数参数表 省略号挂起类型检查机制 它们的出现告知编译器 当函数被调用时 可以有 0 个或多 个实参 而实参的类型未知 省略号有下列两种形式 void foo( parm_list, ... ); void foo( ... ); 第一种形式为特定数目的函数参数提供了声明 在这种情况下 当函数被调用时 对于 与显式声明的参数相对应的实参进行类型检查 而对于与省略号对应的实参则挂起类型检查 在第一种形式中 参数声明后面的逗号是可选的 标准 C 库输出函数 printf()就是一个必须使用省略号的例子 printf()的第一个参数总是 C 风格字符串 int printf( const char* ... ); 这要求 printf()的每次调用都必须传递第一个 const char*型的实参 在 printf()的调用中 字符串后面是否有其他实参由第一个被称作格式字符串的实参所决定 在格式字符串中 由 %开头的元字符表示其他实参的存在 例如 如下调用 printf( "hello, world\n" ); 有一个字符串实参 但是 如下调用 printf( "hello, %s\n", userName ); 有两个实参 %表明需要第二个实参 s 表明该实参的类型是一个字符串 大多数带有省略号的函数都利用显式声明的参数中的一些信息 来获取函数调用中提供 的其他可选实参的类型和数目 同此带有省略号的第一种形式的函数声明最常使用 注意下列两个声明并不等价 void f(); void f( ... ); 在第一个实例中 f()被声明为不接受任何参数的函数 在第二个中 f()被声明为一个要 求 0 个或多个实参的函数 如下的调用 f( someValue ); f( cnt, a, b, c ); 只对第二个声明是合法的 而如下调用 f(); 可用来调用第一个或第二个函数 296 第七章 函数 练习 7.4 下列声明哪些是错误的 为什么 (a) void print( int arr[][], int size ); (b) int ff( int a, int b = 0, int c = 0 ); (c) void operate( int *matrix[] ); (d) char *screenInit( int height = 24, int width, char background ); (e) void putValues( int (&ia)[] ); 练习 7.5 下列这些函数的重新声明都是错误的 为什么 (a) char *screenInit( int height, int width, char background = ' ' ); char *screenInit( int height = 24, int width, char background ); (b) void print( int (*arr)[6], int size ); void print( int (*arr)[5], int size ); (c) void manip( int *pi, int first, int end = 0 ); void manip( int *pi, int first = 0, int end = 0 ); 练习 7.6 已知下列函数声明 下列哪些函数调用是错误的 为什么 // 声明 void print( int arr[][5], int size ); void operate(int *matrix[7]); char *screenInit( int height = 24, int width = 80, char background = ' ' ); (a) screenInit(); // 函数调用 (b) int *matrix[5]; operator( matrix ); // 函数调用 (c) int arr[5][5]; print( arr, 5 ); // 函数调用 练习 7.7 对于 7.3.4 小节中针对 vector给出的 putValues()函数 请重写此函数 使它能处理 list 每行输出一个串 使得包含两个串的 list 输出如下 ( 2 ) < "first string" "second string" > 297 第七章 函数 写一个 main()函数 它调用新的 putValues()函数 使用包含下列值的字符串列表 "put function declarations in header files" "use abstract container types instead of built-in arrays" "declare class parameters as references" "use reference to const types for invariant parameters" "use less than eight parameters" 练习 7.8 什么时候该用指针参数 什么时候该用引用参数 解释它们各自的长处与缺点 7.4 返回一个值 return语句被放在函数体内 这条语句结束当前正在执行的函数 在程序执行期间 遇 到 return 语句时 程序控制权被返回给调用此函数的函数 return 语句有两种形式 return; return expression; 第一种形式用在返回类型为 void 的函数中 在返回类型为 void 的函数中 返回语句不是 必需的 它主要的作用是引起函数的强制结束 return 语句的这种用法与 loop 循环中的 break 语句类似 break 语句在 5.8 节中介绍 隐式的 return 发生在函数的最终语句完成时 例如 void d_copy( double *src, double *dst, int sz ) { /* 将 "src" 复制到 "dst" * 简化假设: 数组大小相同 */ // 如果指针为 0, 返回 if ( !src || !dst ) return; // 如果两个参数引用同一个数组 返回 if ( src == dst ) return; // 没有东西要拷贝 if ( sz == 0 ) return; // 还在这儿? 那么该做一些工作了 for ( int ix = 0; ix < sz; ++ix ) dst[ix] = src[ix]; // 无需显式的 return, 自动返回到调用函数 } return语句的第二种形式提供了函数的结果 结果可以是任意复杂的表达式 同时也叫 298 第七章 函数 以包含函数调用 例如 factorial()的实现包含下列 return 语句 我们将在下一节中看到 factorial()的实现 return val * factorial(val-1); 一个具有返回值 value_returning 的函数 即 函数返回类型没有被声明为 void 必须 返回一个值 缺少返回值将引起编译错误 虽然 C++不能保证一个结果的正确性 但它至少 可以保证为每个具有返回值的函数提供一个结果 例如 下列函数编译将失败 因为它的两 个出口点 exit point 都没有返回值 // definition of the Matrix class interface #include "Matrix.h" bool is_equal( const Matrix &m1, const Matrix &m2 ) { /* 如果两个 Matrix 对象相同 * 则返回 true; * 否则返回 false */ // 比较列数 if ( m1.colSize() != m2.colSize() ) // 程序错误: 没有返回值 return; // 比较行数 if ( m1.rowSize() != m2.rowSize() ) // 程序错误: 没有返回值 return; // 遍历每个 matrix 直到不相等 // 或所有元素都检查完毕 for ( int row = 0; row < m1.rowSize(); ++row ) for ( int col = 0; col < m1.colSize(); ++col ) if ( m1[row][col] != m2[row][col] ) return false; // 程序错误: 没有返回值 // 此时 m1 == m2 } 如果被返回的值的类型与函数返回类型不匹配 那么如果可能的话 将应用隐式类型转 换 如果无法隐式转换 则产生一个编译错误 类型转换在 4.14 节讨论 缺省情况下 函数的返回值是按值传递的 passed by value 这意味着得到控制权的 函数将接收返回语句中指定的表达式的拷贝 例如 Matrix grow( Matrix* p ) { Matrix val; // ... return val; } grow()把存储在 val 中的值的拷贝返回到调用函数 但调用函数不能用任何方式修改 val 299 第七章 函数 该缺省行为可以被改变 一个函数可以被声明为返回一个指针或一个引用 当函数返回 一个引用时 调用函数接收 val 的左值 即 调用函数可以修改 val 或取它的地址 grow() 可以如下声明返回一个引用 Matrix& grow( Matrix* p ) { Matrix *res; // 在动态存储中分配一个更大的 Matrix // res 是指向新 Matrix 的指针 // 将*p 内容复制到*res return *res; } 如果返回值是一个大型类对象 用引用 或指针 返回类型比按值返回类对象效率要高 得多 在某些情况下 编译器自动将按值返回转换到按引用返回 该优化被称为命名返回值 优化 named return value optimization 将在 14.8 节中描述 当声明一个返回引用的函数时 程序员应当知道下面两个易犯的错误 1 返回一个指向局部对象的引用 局部对象的生命期随函数的结束而结束 局部对象的 生命期将在 8.3 节讨论 在函数结束后 该引用变成未定义内存的别名 例如 // 问题: 返回一个指向局部对象的引用 Matrix& add( Matrix &m1, Matrix &m2 ) { Matrix result; if ( m1.isZero() ) return m2; if ( m2.isZero() ) return m1; // 将两个 Matrix 对象的内容相加 // 喔! 返回之后 结果指向一个有问题的位置 return result; } 在这种情况下 返回类型应该被声明为非引用类型 然后再在局部对象的生命期结束之 前 拷贝局部变量 Matrix add( ... 2 函数返回一个左值 对返回值的任何修改都将改变被返回的实际对象 例如 #include int &get_val( vector &vi, int ix ) { return vi[ix]; } int ai[4] = { 0, 1, 2, 3 }; vector vec( ai, ai+4 ); // 将 ai 的 4 个元素复制到 vec 300 第七章 函数 int main() { // 将 vec[0] 增加到 1 get_val( vec,0 )++; // ... } 为防止对引用返回值的无意修改 返回值应该被声明为 const const int &get_val( ... 在 2.3 节里 对于类 IntArray 重载下标操作符的讨论中 我们曾经给出过一个为了修改 被返回的实际对象而返回左值的例子 7.4.1 参数和返回值与全局对象 一个程序中的各种函数可以通过两种机制进行通信 这里的通信 communicate 指的 是值的交换 一种方法是使用全局对象 第一种方法是使用函数参数表和返回值 全局对象 global object 被定义在函数定义之外 例如 int glob; int main() { // 函数体任意 } 对象 glob 是一个全局对象 在第 8 章将进一步讨论全局域和全局对象 在程序的任意 地方都可以访问全局对象是它的主要优势 也是它最大的负担 全局对象的可视性使其成为 程序各部分之间进行通信的一种方便的机制 但函数之间依靠全局对象的通信有下列缺点 使用全局对象的函数依赖于全局对象的存在和类型 这使得在不同上下文环境中重 用该函数更加困难 如果程序必须被修改 则全局依赖增加了引入错误的可能性 而且 既使只对局部 做修改也要求程序员必须理解整个程序 如果全局对象得到一个不正确的值 则必须查找整个程序以判断错误发生的位置 也就是没有实现局部化 当一个函数使用全局对象时 递归更加难以正确完成 递归在程序调用自身时才 发生 我们将在 7.5 节介绍递归 在线程存在的情况下 我们必须做特殊的编码 以便同步各个线程对于全局对象的 读和写操作 当我们在使用线程时 缺少同步是程序错误的常见根源 关于 C++ 中的线程程序设计 见 Steve Vinoski 和 Doug Sohmidt 在 LIPPMAN96b 中的文章 C++中的分布式对象计算 因此 建议程序中的函数使用参数表和返回值进行通信 向一个函数传递参数发生错误的可能性随参数表的长度的增加而提高 作为一个通用规 则 8 个参数应该是最大值了 为了替换一个大型的参数表 程序员可以将参数声明为类 数组或某一种容器类型 这样的参数可以用来包含一组参数值 类似的情况 一个函数只能返回一个值 如果程序的逻辑要求返回多个值 那么程序员 可以将某些函数参数声明为引用 在这种情况下 函数可以直接修改相应的实参 因而程序 301 第七章 函数 员可以设置这些实参含有某些额外的 返回 值 或者程序员可以声明一个函数 它的返回 类型是一个可以包含一组返回值的类或某一种容器类型 练习 7.9 return 语句的两种形式是什么 使用每一种形式的条件是什么 练习 7.10 你知道下列函数定义有什么潜在的运行问题吗 vector &readText( ) { vector text; string word; while ( cin >> word ) { text.push_back( word ); // ... } // .... return text; } 练习 7.11 怎样从一个函数返回一个以上的值 描述你所选方法的优缺点 7.5 递归 直接或间接调用自己的函数被称为递归函数 recursive function 下面的函数 rgcd()就 是一个速归函数 int rgcd( int v1, int v2 ) { if ( v2 != 0 ) return rgcd( v2, v1%v2 ); return v1; } 递归函数必须定义一个停止条件 stopping condition 否则 函数会 永远 递归下 去 有时候 这被称作无限递归 infinit recursion 错误 在 rgcd()的情况下 停止条件是 余数为 0 如下调用 rgcd( 15, 123 ); 302 第七章 函数 计算结果为 3 表 7.1 跟踪它的执行情况 表格 7.1 rgcd(15, 123) 的跟踪结果 v1 v2 返回值 15 123 rgcd(123,15) 123 15 rgcd(15,3) 15 3 rgcd(3,0) 3 0 3 最后一个调用 rgcd(3, 0); 满足了停止条件 它返回最大公约数 3 该值依次成为前面每个调用的返回值 这个值 被称为回渗 percolate 直到执行返回到第一次调用 rgcd()的函数 由于与函数调用相关的额外开销 递归函数可能比非递归 或称迭代 函数执行得慢一 些 但是 递归函数可能更小且更易于理解 N 的阶乘 factorial 是将数从 1 乘到 n 的结果 例如 5 的阶乘是 120 1 2 3 4 5 = 120 阶乘的计算可以用递归函数实现 unsigned long factorial( int val ) { if ( val > 1 ) return val * factorial( val-1 ); return 1; } 本例的结束条件发生在 val 的值为 1 时 练习 7.12 将 factorial()重写为送代函数 练习 7.13 如果 factorial()的结束条件如下所示 将会发生什么 if ( val != 0 ) 7.6 inline 函数 考虑下列 min()函数 int min( int v1, int v2 ) { 303 第七章 函数 return( v1 < v2 << v1 : v2 ); } 为这样的小操作定义一个函数的好处是 如果一段代码包含 min()的调用 那么阅读这样的代码并解释它的含义比读一个条 件操作符的实例以及理解代码在做什么 尤其是复杂表达式时要容易得多 改变一个局部化的实现比更改一个应用中的 300 个出现要容易得多 例如 如果决 定测试条件应该是 ( v1 == v2 || v1 < v2 ) 那么 找到该代码的每一个出现将非常乏味而且容易出错 语义是统一的 每个测试都保证以相同的方式实现 函数可以被重用 不必为其他的应用重写代码 但是 将 min()写成函数有一个严重的缺点 调用函数比直接计算条件操作符要慢得多 不但必须拷贝两个实参 保存机器的寄存器 程序还必须转向一个新位置 同此 手写的条 件操作符能快得多 inline 内联 函数给出了一种解决方案 若一个函数被指定为 inline 函数 则它将在程 序中每个调用点上被 内联地 展开 例如 int minVal2 = min( i, j ); 在编译时被展开为 int minVal2 = i < j << i : j; 把 min()写成函数的额外执行开销从而被消除了 在函数声明或定义中的函数返回类型前加上关键字 inline 即把 min()指定成为 inline inline int min( int v1, int v2 ) { /* ... */ } 但是 注意 inline 指示对编译器来说只是一个建议 编译器可以选择忽略该建议 因为 把一个函数声明为 inline 函数 并不见得真的适合在调用点上展开 例如 一个递归函数 如 rgcd()并不能在调用点完全展开 虽然它的第一个调用可以 一个 1200 行的函数也不太 可能在调用点展开 一般地 inline 机制用来优化小的 只有几行的 经常被调用的函数 在抽象数据类的设计中 它对支持信息隐藏起着主要作用 比如在 2.3 节中介绍的 IntArray 类的 size()inline 成员函数 inline 函数对编译器而言必须是可见的 以便它能够在调用点内联展开该函数 与非 inline 函数不同的是 inline 函数必须在调用该函数的每个文本文件中定义 当然 对于同一程序 的不同文件 如果 inline 函数出现的话 其定义必须相同 对于由两个文件 compute.C 和 draw.C 构成的程序来说 程序员不能定义这样的 min()函数 它在 compute.C 中指一件事情 而在 draw.C 中指另外一件事情 如果两个定义不相同 程序将会有未定义的行为 编译器最终会 使用这些不同定义中的哪一个作为非 inline 函数调用的定义是不确定的 因而程序的行为可 能并不像你所期望的 为保证不会发生这样的事情 建议把 inline 函数的定义放到头文件中 在每个调用该 inline 函数的文件中包含该头文件 这种方法保证对每个 inline 函数只有一个定义 且程序员无需 304 第七章 函数 复制代码 并且不可能在程序的生命期中引起无意的不匹配的事情 因为 min()是一个常见操作 所以 C++标准库提供了 min()的一个实现 min()操作是第 12 章介绍的泛型算法的一部分 它的用法将在附录中说明 标准库把 min()定义为模板 允许它 应用在非 int 型的算术型操作数上 函数模板将在第 10 章中讨论 7.7 链接指示符 extern C 如果程序员希望调用其他程序设计语言 尤其是 C 写的函数 那么 调用函数时必须 告诉编译器使用不同的要求 例如 当这样的函数被调用时 函数名或参数排列的顺序可能 不同 无论是 C++函数调用它 还是用其他语言写的函数调用它 程序员用链接指示符 linkage directive 告诉编译器 该函数是用其他的程序设计语言 编写的 链接指示符有两种形式 既可以是单一语句 single statement 形式 也可以是复 合语句 compound statement 形式 // 单一语句形式的链接指示符 extern "C" void exit(int); // 复合语句形式的链接指示符 extern "C" { int printf( const char* ... ); int scanf( const char* ... ); } // 复合语句形式的链接指示符 extern "C" { #include } 链接指示符的第一种形式由关键字 extern 后跟一个字符串常量以及一个 普通 的函数 声明构成 虽然函数是用另外一种语言编写的 但调用它仍然需要类型检查 例如 编译器 会检查传递给函数 exit()的实参的类型是否是 int 或者能够隐式地转换成 int 型 多个函数声明可以用花括号包含在链接指示符复合语句中 这是链接指示符的第二种形 式 花招号被用作分割符 表示链接指示符应用在哪些声明上 在其他意义上该花括号被忽 略 所以在花括号中声明的函数名对外是可见的 就好像函数是在复合语句外声明的一样 例如 在前面的例子中 复合语句 extern "C"表示函数 printf()和 scanf()是在 C 语言中写的 函数 因此 这个声明的意义就如同 printf()和 scanf()是在 extern "C"复合语句外面声明的 一样 当复合语句链接指示符的括号中含有#include 时 在头文件中的函数声明都被假定是用 链接指示符的程序设计语言所写的 在前面的例子中 在头文件中声明的函数都是 C 函数 链接指示符不能出现在函数体中 下列代码段将会导致编译错误 int main() { // 错误: 链接指示符不能出现在函数内 extern "C" double sqrt( double ); 305 第七章 函数 double getValue(); //ok double result = sqrt ( getValue() ); //... return 0; } 如果把链接指示符移到函数体外 程序编译将无错误 extern "C" double sqrt( double ); int main() { double getValue(); //ok double result = sqrt ( getValue() ); //... return 0; } 但是 把链接指示符放在头文件中更合适 在那里 函数声明描述了函数的接口所属 如果我们希望 C++函数能够为 C 程序所用 又该怎么办呢 我们也可以使用 extern "C" 链接指示符来使 C++函数为 C 程序可用 例如 // 函数 calc() 可以被 C 程序调用 extern "C" double calc( double dparm ) { /* ... */ } 如果一个函数在同一文件中不只被声明一次 则链接指示符可以出现在每个声明中 它 也可以只出现在函数的第一次声明中 在这种情况下 第二个及以后的声明都接受第一个声 明中链接指示符指定的链接规则 例如 // ---- myMath.h ---- extern "C" double calc( double ); // ---- myMath.C ---- // 在 Math.h 中的 calc() 的声明 #include "myMath.h" // 定义了 extern "C" calc() 函数 // calc() 可以从 C 程序中被调用 double calc( double dparm ) { // ... 在本节中 我们只看到为 C 语言提供的链接指示 extern "C" extern "C"是惟一被 保证由所有 C++实现都支持的 每个编译器实现都可以为其环境下常用的语言提供其他链接 指示 例如 extern "Ada"可以用来声明是用 Ada 语言写的函数 extern "FORTRAN"用来 声明是用 FORTRAN 语言写的函数 等等 因为其他的链接指示随着具体实现的不同而不同 所以建议读者查看编译器的用户指南 以获得其他链接指示符的进一步信息 本节介绍了 C++中的关键字 extern 的第一种用法 在 8.2 节中 我们将看到 extern 的其 他有关对象和函数声明的用法 练习 7.14 exit() printf() malloc() strcpy()以及 strlen()都是 C 语言库例程 修改下列 C 程序使其 306 第七章 函数 能在 C++下编译链接 const char *str = "hello"; void *malloc( int ); char *strcpy( char *, const char * ); int printf( const char *, ... ); int exit( int ); int strlen( const char * ); int main() { /* C 语言程序 */ char* s = malloc( strlen(str)+1 ); strcpy( s, str ); printf( "%s, world\n", s ); exit( 0 ); } 7.8 main() 处理命令行选项 通常 在执行程序时 我们会传递命令行选项 例如 我们可能写如下命令行 prog -d -o ofile data0 实际上 命令行选项是 main()的实参 在 main()函数中 我们可以通过一个名为 argv 的 C 风格字符串数组访问它 在本节中 我们将说明怎样支持命令行选项 在本节之前 所有的 main()函数都声明了一个空的参数表 int main() { ... } 如果用户已在命令行中指定了选项的话 那么我们可以通过 main()函数的一种扩展原型 特征来访问这些选项 int main( int argc, char *argv[] ) { ... } argc包含命令行选项的个数 argv 包含 aygc 个 C 风格字符串 代表了由空格分隔的命令 选项 例如 对于如下命令行 prog -d -o ofile data0 argc被设置为 5 且 argv 被设置为下列 C 风格字符串 argv[ 0 ] = "prog"; argv[ 1 ] = "-d"; argv[ 2 ] = "-o"; argv[ 3 ] = "ofile"; argv[ 4 ] = "data0"; argv[0]总是被设置为当前正被调用的命令 从索引 1 到 argc-1 表示被传递给命令的实际 选项 让我们来看一下如何取出在 argv 中的命令行选项 在我们的例子中 将支持下列用法 program_name [-d] [-h] [-v] 307 第七章 函数 [-o output_file] [-l limit_value] file_name [ file_name [file_name [ ... ]]] 方括号中的内容是可选的 例如 最小的命令行只给出要处理的文件 prog chap1.doc 其他可能的调用方式如下 prog -l 1024 -o chap1-2.out chap1.doc chap2.doc prog -d chap3.doc prog -l 512 -d chap4.doc 处理命令行选项的基本步骤如下 1 按顺序从 argv 中取出每个选项 我们将用 for 循环来完成 从索引 1 开始迭代 所以 跳过程序名 for ( int ix = 1; ix < argc; ++ix ) { char *pchar = argv[ ix ]; // ... } 2 确定选项的类型 如果它以 - 开始的 我们就能知道它是{h,d,v,l,o}之一 否则 它或许是与-l 相关的实际限制量 或者是与-o 相关的输出文件名 或者是程序要处理的文件 名 我们将用 switch 语句确定是否存在一个 - switch ( pchar[ 0 ] ) { case '-': { // 识别 -h, -d, -v, -l, -o } default: { // 处理 -l 后的限制值 // -o 后面的输出文件 // 文件名 ... } } 3 填写代码 处理第 2 项中的两个 case 如果存在 - 则我们只是简单地切换到下一个字符 确定用户指定的选项 下面是实现代码的一般轮廓 case '-': { switch( pchar[ 1 ] ) { case 'd': // 处理调试 break; case 'v': // 处理版本请求 break; case 'h': // 处理帮助 308 第七章 函数 break; case 'o': // 准备处理输出文件 break; case 'l': // 准备处理限制量 break; default: // 无法辨识的选项 // 报告并退出 } } 选项-d 打开调试 为处理它 我们把一个对象 bool debug_on = false; 设置为 true case 'd': debug_on = true; break; 我们的程序可能包含如下代码 if ( debug_on ) display_state_elements( obj ); 选项-v 显示程序的版本号 然后结束 case 'v': cout << program_name << "::" << program_version << endl; return 0; 选项-h 生成程序的 usage()消息 然后结束 结束在 usage()中完成 case 'h': // 无需 break: usage() 会退出 usage(); 选项-o 表明用户指定的输出文件名紧随其后 类似地 选项-l 表明后面是限制值 limit value 该怎样处理它呢 如果不存在 - 我们知道应该有一个限制值 或者用户指定的输出文件 或者要被处 理的文件的名字 为区分这三种可能 我们将记录内部状态的对象设置为 true // 如果为 true, 则下一个实参是输出文件 bool ofile_on = false; // 如果为 true, 则下一个实参是限制值 bool limit_on = false; 在实现代码的选项处理部分里 case 'l': 309 第七章 函数 limit_on = true; break; case 'o': ofile_on = true; break; 当我们遇到一个不以 - 开头的实参时 我们测试状态对象以便确定选项所表达的内容 // 三种可能: limit_value, output_file 或者 file_name default: { // 如果看到 -o, 则设置 ofile_on if ( ofile_on ) { // 处理 output_file // 关闭 ofile_on } else if ( limit_on ) { // 如果见到 -l // 处理 limit_value // 关闭 limit_on } else { // 处理 file_name } } 如果选项是一个输出文件 我们将 ofile_on 复位为 false 并取出文件名 if ( ofile_on ) { ofile_on = false; ofile = pchar; } 如果实参是个限制值 我们需要把 C 风格的字符串转换成数值表示 我们用标准库函数 atoi() 为了使用 atoi() 我们将包含 ctype.h 头文件 此外 我们还必须保证限制值是个非负 值 并必须将 limit_on 复位为 false // int limit; else if ( limit_on ) { limit_on = false; limit = atoi( pchar ); if ( limit < 0 ) { cerr << program_name << "::" << program_version << " : error: " << "negative value for limit.\n\n"; usage( -2 ); } } 否则 如果状态对象都不是 true 则认为是一个文件 将来可以打开来处理 我们将它 的名字存储在一个字符串 vector 中 else file_names.push_back( string( pchar )); 当我们处理命令行时 可能最重要的设计部分在于选择处理无效选项的方式 例如 我 310 第七章 函数 们认为指定一个负的限制值为一个严重的错误 这或许合适也或许不合适 或许我们可以认 为它越界了 警告用户 并将其值设置为 0 或其他有意义的缺省值 当用户弄乱了命令行的空格分隔符 我们的程序就会出现了两个明显的缺点 例如 下 列两个命令行都不能处理 prog - d data01 prog -oout_file data01 我们把它们都留作本节最后的练习 下面是程序的完整实现 我们已经加入了输出语句来说明它的处理过程 #include #include #include #include const char *const program_name = "comline"; const char *const program_version = "version 0.01 (08/07/97)"; inline void usage( int exit_value = 0 ) { // 输出一个格式化的用法信息 // 并用 exit_value 退出... cerr << "usage:\n" << program_name << " " << "[-d] [-h] [-v] \n\t" << "[-o output_file] [-l limit] \n\t" << "file_name\n\t[file_name [file_name [ ... ]]]\n\n" << "where [] indicates optional option: \n\n\t" << "-h: help.\n\t\t" << "generates this message and exits\n\n\t" << "-v: version.\n\t\t" << "prints version information and exits\n\n\t" << "-d: debug.\n\t\tturns debugging on\n\n\t" << "-l limit\n\t\t" << "limit must be a non-negative integer\n\n\t" << "-o ofile\n\t\t" << "file within which to write out results\n\t\t" << "by default, results written to standard output \n\n" << "file_name\n\t\t" << "the name of the actual file to process\n\t\t" << "at least one file_name is required --\n\t\t" << "any number may be specified\n\n" << "examples:\n\t\t" << "$command chapter7.doc\n\t\t" << "$command -d -l 1024 -o test_7_8 " << "chapter7.doc chapter8.doc\n\n"; exit( exit_value ); } int main( int argc, char* argv[] ) 311 第七章 函数 { bool debug_on = false; bool ofile_on = false; bool limit_on = false; int limit = -1; string ofile; vector file_names; cout << "illustration of handling command line arguments:\n" << "argc: " << argc << endl; for ( int ix = 1; ix < argc; ++ix ) { cout << "argv[ " << ix << " ]: " << argv[ ix ] << endl; char *pchar = argv[ ix ]; switch ( pchar[ 0 ] ) { case '-': { cout << "case \'-\' found\n"; switch( pchar[ 1 ] ) { case 'd': cout << "-d found: " << "debugging turned on\n"; debug_on = true; break; case 'v': cout << "-v found: " << "version info displayed\n"; cout << program_name << " :: " << program_version << endl; return 0; case 'h': cout << "-h found: " << "help information\n"; // 这里没必要用 break 了, usage() 可以退出 usage(); case 'o': cout << "-o found: output file\n"; ofile_on = true; break; case 'l': cout << "-l found: " << "resource limit\n"; 312 第七章 函数 limit_on = true; break; default: cerr << program_name << " : error : " << "unrecognized option: - " << pchar << "\n\n"; // 这里没必要用 break 了, usage() 可以退出 usage( -1 ); } break; } default: // 或文件名 cout << "default nonhyphen argument: " << pchar << endl; if ( ofile_on ) { ofile_on = false; ofile = pchar; } else if ( limit_on ) { limit_on = false; limit = atoi( pchar ); if ( limit < 0 ) { cerr << program_name << " : error : " << "negative value for limit.\n\n"; usage( -2 ); } } else file_names.push_back( string( pchar )); break; } } if ( file_names.empty() ) { cerr << program_name << " : error : " << "no file specified for processing.\n\n"; usage( -3 ); } if ( limit != -1 ) cout << "User-specifed limit: " << limit << endl; if ( ! ofile.empty() ) cout << "User-specified output file: " << ofile << endl; cout << (file_names.size() == 1 ? "File " : "Files ") 313 第七章 函数 << "to be processed are the following:\n"; for ( int inx = 0; inx < file_names.size(); ++inx ) cout << "\t" << file_names[ inx ] << endl; } 下面是程序的执行练习 a.out -d -l 1024 -o test_7_8 chapter7.doc chapter8.doc 下面是命令行选项处理的跟踪结果 illustration of handling command line arguments: argc: 8 argv[ 1 ]: -d case '-' found -d found: debugging turned on argv[ 2 ]: -l case '-' found -l found: resource limit argv[ 3 ]: 1024 default nonhyphen argument: 1024 argv[ 4 ]: -o case '-' found -o found: output file argv[ 5 ]: test_7_8 default nonhyphen argument: test_7_8 argv[ 6 ]: chapter7.doc default nonhyphen argument: chapter7.doc argv[ 7 ]: chapter8.doc default nonhyphen argument: chapter8.doc User-specifed limit: 1024 User-specified output file: test_7_8 Files to be processed are the following: chapter7.doc chapter8.doc 7.8.1 一个处理命令行的类 我们最好是把处理命令行选项的细节封装起来 使得它不会扰乱 main() 一种封装策略 当然是提供一个函数 例如 extern int parse_options( int arg_count, char **arg_vector ); int main( int argc, char *argv[] ) { // ... int option_status; option_status = parse_options( argc, argv ); // ... } 这个设计的问题在于 怎样返回由用户传递的值的集合 典型情况下 这些值被定义成 全局对象 不需要在函数之间传来传去 另外一种方法是 我们可以把处理过程封装到一个 类 class 中 314 第七章 函数 类的数据成员是代表用户可能设置的值的对象 一组公有 inline 函数提供了对这些值的 访问途径 构造函数将这些值初始化为其缺省设置 一个成员函数取 argc 和 argv 做实参 并提供对于选项的处理 #include #include class CommandOpt { public: CommandOpt() : _limit( -1 ), _debug_on( false ) {} int parse_options( int argc, char *argv[] ); string out_file() { return _out_file; } bool debug_on() { return _debug_on; } int files() { return _file_names.size(); } // 访问 _file_names string& operator[]( int ix ); private: inline int usage( int exit_value = 0 ); bool _debug_on; int _limit; string _out_file; vector _file_names; static const char *const program_name; static const char *const program_version; }; 下面是修改后的 main()19 #include "CommandOpt.h" int main( int argc, char *argv[] ) { // ... CommandOpt com_opt; int option_status; option_status = com_opt.parse_options(argc,argv); // ... } 练习 7.15 增加选项-t 打开计时器 以及选项-b 提供 bufsize 实参 的处理 来确保同时更改 usage() 例如 prog -t -b 512 data0 19 CommandOpt 类的完整实现可以在 Addison-Wesley Web 网站上找到 315 第七章 函数 练习 7.16 我们的实现不能处理在选项与其相关值之间没有空格的情况 理想情况下 我们可以接 受有或没有空格的选项 请修改实现代码来做到这一点 练习 7.17 我们的实现现在不能处理在 - 和选项之间增加空格的情况 如 prog - d data0 修改我们的实现识别并显式地标记这个错误 练习 7.18 我们的实现不能识别选项-l 和-o 的多个实例 修改实现来完成它 策略是什么 练习 7.19 如果用户指定一个未知选项 我们的实现将产生一个致命错误 你认为这合理吗 我们 还可以怎么做 练习 7.20 为以加号 + 开头的选项增加支持 为选项+s +pt +sp 以及+ps 提供处理 假定+s 打开严格处理 而+p 支持现在已经过时的前构造过程 previous construct 例如 prog +s +p -d -b 1024 data0 7.9 指向函数的指针 假定我们被要求提供一个如下形式的排序函数 sort( start, end, compare ); start和 end 是指向字符串数组中元素的指针 函数 sort()对于 start 和 end 之间的数组元 素进行排序 compare 定义了比较数组中两个字符串的比较操作 该怎样实现 compare 呢 我们或许想按字典顺序排序数组内的字符串——即 与字典中 相同的方式排序单词 或许想按长度排序它们 以便将最短的字符串放在前面 而长的放在 后面 指定可替换的比较操作需要某种设施 第 12 章将描述 sort()函数以及 C++标准库提供的其他泛型算法 在本节中 为说明函 数指针的用法 我们自己写 sort()函数 它是 C++标准库提供的函数的简化版本 解决这些需求的一种策略是将第三个参数 compare 设为函数指针 并由它指定要使用的 比较函数 为简化 sort()的用法而又不限制它的灵活性 我们可能希望指定一个缺省的比较函数 以 用于大多数的情况 让我们假设最常见的以字典序排列字符串的情况 缺省实参将指定一个 316 第七章 函数 比较操作 它用到了字符串的 compare()函数 这个函数在 6.10 节首次介绍 本节我们将考虑怎样用函数指针来实现我们的 sort()函数 7.9.1 指向函数的指针的类型 怎样声明指向函数的指针呢 用函数指针作为实参的参数会是什么样呢 下面是函数 lexicoCompare()的定义 它按字典序比较两个字符串 #include int lexicoCompare( const string &s1, const string &s2 ) { return s1.compare(s2); } 如果字符串 s1 和 s2 中的所有字符都相等 则 lexicoCompare()返回 0 否则 如果第一 个参数表示的字符串小于第二个参数表示的字符串 则返回一个负数 如果大于 则返回一 个正数 函数名不是其类型的一部分 函数的类型只由它的返回值和参数表决定 指向 lexicoCompare()的指针必须指向与 lexicoCompare()相同类型的函数 带有相同的返回类型和 相同的参数表 让我们试一下 int *pf( const string &, const string & ); // 喔! 差一点 这几乎是正确的 问题是编译器把该语句解释成名为 pf 的函数的声明 它有两个参数 并且返回一个 int*型的指针 参数表是正确的 但是返回值不是我们所希望的 解引用操作 符 * 应与返回类型关联 所以在这种情况下 是与类型名 int 关联 而不是 pf 要想让解 引用操作符与 pf 关联 括号是必需的 int (*pf)( const string &, const string & ); // ok: 正确 这个语句声明了 pf 是一个指向函数的指针 该函数有两个参数和 int 型的返回值 即指 向函数的指针 它与 lexicoCompare()的类型相同 下列函数与 lexicoCompare()类型相同 都可以用 pf 来指向 int sizeCompare( const string &, const string & ); 但是 calc()和 gcd()与前面两个函数的类型不同 不能用 Pf 来指 int calc( int , int ); int gcd( int , int ); 可以如下定义 pfi 它能够指向这两个函数 int (*pfi)( int, int ); 省略号是函数类型的一部分 如果两个函数具有相同的参数表 但是一个函数在参数表 末尾有省略号 则它们被视为不同的函数类型 指向这两个函数的指针类型也不同 int printf( const char*, ... ); int strlen( const char* ); int (*pfce)( const char*, ... ); // 可以指向 printf() int (*pfc)( const char* ); // 可以指向 strlen() 函数返回类型和参数表的不同组合 代表了各不相同的函数类型 317 第七章 函数 7.9.2 初始化和赋值 我们知道 不带下标操作符的数组名会被解释成指向首元素的指针 当一个函数名没有 被调用操作符修饰时 会被解释成指向该类型函数的指针 例如 表达式 lexicoCompare; 被解释成类型 int (*)( const string &, const string & ); 的指针 将取地址操作符作用在函数名上也能产生指向该函数类型的指针 因此 lexicoCompare 和&lexioCompare 类型相同 指向函数的指针可如下被初始化 int (*pfi)( const string &, const string & ) = lexicoCompare; int (*pfi2)( const string &, const string & ) = &lexicoCompare; 指向函数的指针可以如下被赋值 pfi = lexicoCompare; pfi2 = pfi; 只有当赋值操作符左边指针的参数表和返回类型与右边函数或指针的参数表和返回类型 完全匹配时 初始化和赋值才是正确的 如果不匹配 则将产生编译错误消息 在指向函数 类型的指针之间不存在隐式类型转换 例如 int calc( int, int ); int (*pfi2s)( const string &, const string & ) = 0; int (*pfi2i)( int, int ) = 0; int main() { pfi2i = calc; // ok pfi2s = calc; // 错误: 类型不匹配 pfi2s = pfi2i; // 错误: 类型不匹配 return 0; } 函数指针可以用 0 来初始化或赋值 以表示该指针不指向任何函数 7.9.3 调用 指向函数的指针可以被用来调用它所指向的函数 调用函数时 不需要解引用操作符 无论是用函数名直接调用函数 还是用指针间接调用函数 两者的写法是一样的 例如 #include int min( int*, int ); int (*pf)( int*, int ) = min; const int iaSize = 5; int ia[ iaSize ] = { 7, 4, 9, 2, 5 }; int main() { cout << "Direct call: min: " 318 第七章 函数 << min( ia, iaSize ) << endl; cout << "Indirect call: min: " << pf( ia, iaSize ) << endl; return 0; } int min( int* ia, int sz ) { int minVal = ia[ 0 ]; for ( int ix = 1; ix < sz; ++ix ) if ( minVal > ia[ ix ] ) minVal = ia[ ix ]; return minVal; } 调用 pf( ia, iaSize ); 也可以用显式的指针符号写出 (*pf)( ia, iaSize ); 这两种形式产生相同的结果 但是第二种形式让读者更清楚该调用是通过函数指针执行 的 当然 如果函数指针的值为 0 则两个调用都将导致运行时刻错误 只有已经被初始化 或赋值的指针 引用到一个函数 才可以被安全地用来调用一个函数 7.9.4 函数指针的数组 我们可以声明一个函数指针的数组 例如 int (*testCases[10])(); 将 testCases 声明为一个拥有 10 个元素的数组 每个元素都是一个指向函数的函数指针 该函数没有参数 返回类型为 int 像数组 testCases 这样的声明非常难读 因为很难分析出函数类型与声明的哪部分相关 在这种情况下 使用 typedef 名字可以使声明更为易读 例如 // typedefs 使声明更易读 typedef int (*PFV)(); // 定义函数类型指针的 typedef PFV testCases[10]; testCases的这个声明与前面的等价 由 testCases 的一个元素引用的函数调用如下 const int size = 10; PFV testCases[size]; int testResults[size]; void runtests() { for ( int i = 0; i < size; ++i ) // 调用一个数组元素 319 第七章 函数 testResults[ i ] = testCases[ i ](); } 函数指针的数组可以用一个初始化列表来初始化 该表中每个初始值都代表了一个与数 组元素类型相同的函数 例如 int lexicoCompare( const string &, const string & ); int sizeCompare( const string &, const string & ); typedef int ( *PFI2S )( const string &, const string & ); PFI2S compareFuncs[2] = { lexicoCompare, sizeCompare }; 我们也可以声明指向 compareFuncs 的指针 这种指针的类型是 指向函数指针数组的指 针 声明如下 PFI2S (*pfCompare)[2] = &compareFuncs; 声明可以分解为 (*pfCompare) 解引用操作符 * 把 pfCompare 声明为指针 后面的[2]表示 pfCompare 是指向两个元 素数组的指针 (*pfCompare)[2] typedef PFI2S 表示数组元素的类型 它是指向函数的指针 该函数返回 int 有两个 const string&型的参数 数组元素的类型与表达式&lexicoCompare 的类型相同 也与 compareFuncs 的第一个元素的类型相同 此外 它还可以通过下列语句之一获得 compareFuncs[ 0 ]; (*pfCompare)[ 0 ]; 要通过 pfCompare 调用 lexicoCompare 程序员可用下列语句之一 // 两个等价的调用 pfCompare[ 0 ]( string1, string2 ); // 编写 ((*pfCompare)[ 0 ])( string1, string2 ); // 显式 7.9.5 参数和返回类型 现在我们回头看一下本节开始提出的问题 在那里给出的任务要求我们写一个排序函数 怎样用函数指针写这个函数呢 因为函数参数可以是函数指针 所以我们把表示所用比较操 作的函数指针作为参数传递给排序函数 int sort( string*, string*, int (*)( const string &, const string & ) ); 我们再次用 typedef 名字使 sort()的声明更易读 // typedef 使 sort() 的声明更易读 typedef int ( *PFI2S )( const string &, const string & ); 320 第七章 函数 int sort( string*, string*, PFI2S ); 因为在多数情况下使用的函数是 lexicoCompare() 所以我们让它成为缺省的函数指针参 数 // 提供缺省参数作为第三个参数 int lexicoCompare( const string &, const string & ); int sort( string*, string*, PFI2S = lexicoCompare ); sort()函数的定义可能像这样 void sort( string *s1, string *s2, PFI2S compare = lexicoCompare ) { // 递归的停止条件 if ( s1 < s2 ) { string elem = *s1; string *low = s1; string *high = s2 + 1; for (;;) { while ( compare( *++low, elem ) < 0 && low < s2) ; while ( compare( elem, *--high ) < 0 && high > s1) ; if ( low < high ) low->swap(*high); else break; } // end, for(;;) s1->swap(*high); sort( s1, high - 1, compare ); sort( high + 1, s2, compare ); } // end, if ( s1 < s2 ) } sort()是 C.A.R.Hoare 的快速排序 quicksort 算法的一个实现 让我们详细查看该函数 的定义 该函数对 s1 和 s2 之间的数组元素进行排序 sort()是一个递归函数 它将自己逐步 地应用在较小的子数组上 停止条件是当 s1 指向与 s2 相同的元素时或指向 s2 所指元素之后 的元素 第 5 行 elem 第 6 行 被称作分割元素 partition element 所有按字典序小于 elem 的元素部 会被移到 elem 的左边 而所有大于的都被移到右边 现在 数组被分成若干个子数组 sort() 被递归地应用在它们之上 第 20-21 行 for(;;)循环的目的是完成分割 第 10-17 行 在循环的每次迭代中 low 首先被向前移 动到第一个大于等于 elem 的数组元素的索引上 第 11 行 类似地 high 一直被递减 直 到移动到小于等于 elem 的数组最右元素的索引上 第 12 行 如果 low 不再小于 high 则 表示元素已经分隔完毕 循环结束 否则 这两个元素被交换 下一次迭代开始 第 14-16 行 虽然数组已经被分隔 但 elem 仍然是数组的第一个元素 在 sort()被应用到两个子数 组之前 第 19 行的 swap()把 elem 放到它在数组中最终正确的位置上 数组元素的比较通过调用 compare 指向的函数来完成 第 11-12 行 swap()字符串操 作被调用 以便交换数组元素所指的字符串 swap()字符串操作在 6.11 节介绍 321 第七章 函数 下面 main()的实现用到了我们的排序函数 #include #include // 这些通常应该在头文件中 int lexicoCompare( const string &, const string & ); int sizeCompare( const string &, const string & ); typedef int (*PFI)( const string &, const string & ); void sort( string *, string *, PFI=lexicoCompare ); string as[10] = { "a", "light", "drizzle", "was", "falling", "when", "they", "left", "the", "museum" }; int main() { // 调用 sort(), 使用缺省实参作比较操作 sort( as, as + sizeof(as)/sizeof(as[0]) - 1 ); // 显示排序之后的数组的结果 for ( int i = 0; i < sizeof(as)/sizeof(as[0]); ++i ) cout << as[ i ].c_str() << "\n\t"; } 编译并执行程序 生成下列输出 "a" "drizzle" "falling" "left" "light" "museum" "the" "they" "was" "when" 函数参数的类型不能是函数类型 函数类型的参数将被自动转换成该函数类型的指针 例如 // typedef 表示一个函数类型 typedef int functype( const string &, const string & ); void sort( string *, string *, functype ); 编译器把 sort()当作已经声明为 void sort( string *, string *, int (*)( const string &, const string & ) ); 上面这两个 sort()的声明是等价的 注意 除了用作参数类型之外 函数指针也可以被用作函数返回值的类型 例如 int (*ff( int ))( int*, int ); 该声明将 ff()声明为一个函数 它有一个 int 型的参数 返回一个指向函数的指针 类型 为 int (*) ( int*, int ); 322 第七章 函数 同样 使用 typedef 名字可以使声明更容易读懂 例如 下面的 typedef PF 使得我们能更 容易地分解出 ff()的返回类型是函数指针 // typedef 使声明更易读 typedef int (*PF)( int*, int ); PF ff( int ); 函数不能声明返回一个函数类型 如果是 则产生编译错误 例如 函数 ff()不能如下 声明 // typedef 表示一个函数类型 typedef int func( int*, int ); func ff( int ); // 错误: ff()的返同类型为函数类型 7.9.6 指向 extern "C"函数的指针 我们可以声明一个函数指针 它指向用其他程序设计语言编写的函数 我们可通过使用 链接指示符来做到这一点 例如 指针 pf 指向一个 C 函数 extern "C" void (*pf)(int); 当用 pf 调用一个函数时 被调用的函数是一个 C 函数 extern "C" void exit(int); // pf 指向 C 函数 exit() extern "C" void (*pf)(int) = exit; int main() { // ... // 调用名为 exit() 的 C 函数 (*pf)(99); } 指向 C 函数的指针与指向 C++函数的指针类型不同 记住 对于函数指针的初始化或者 赋值 只有当被赋值的指针类型与赋值操作符右边的指针或函数完全匹配时 初始化或者赋 值才是合法的 因此 指向 C 函数的指针不能用指向 C++函数的指针初始化或赋值 反之亦 然 如果没有这样做 就会产生编译错误 例如 void (*pf1)(int); extern "C" void (*pf2)(int); int main() { pf1 = pf2; // 错误: pf1 和 pf2 类型不同 // ... } 注意 对于某些 C++的实现 指向 C 函数指针的特性与指向 C++的相同 有些编译器可 能接受上面的赋值作为语言的一种扩展 当链接指示符应用在一个声明上时 所有被它声明的函数都将受到链接指示符的影响 在下面的例子中 参数 pfParm 也是一个 C 函数指针 链接指示符应用在该参数指向的函数 323 第七章 函数 上 // pfParm 是一个指向 C 函数的指针 extern "C" void f1( void(*pfParm)(int) ); 因此 f1()是一个 C 函数 它有一个指向 C 函数指针的参数 因为指向 C 函数的指针与 指向 C++函数的指针类型不同 所以传递给 f1()的实参必须是 C 函数名或指向 C 函数的指针 再次说明 在 C 函数指针与 C++函数指针有相同特性的编译器实现中 编译器可能会支持 一种语言扩展 允许向 f1()传递一个 C++函数指针作为实参 由于链接指示符作用在声明中所有的函数上 那么我们应该怎样声明一个含有 C 函数指 针的 C++函数的参数呢 解决方案是用 typedef 例如 // FC 表示一个 C 函数类型 // 有一个 int 参数和 void 返回值 extern "C" typedef void FC( int ); // f2() 是一个带有一个参数的 C++函数 // 参数是一个 C 函数指针 void f2( FC *pfParm ); 练习 7.21 7.5 节定义了函数 factorial() 定义一个函数指针 使它能够指向 factorial() 并通过该指 针生成 11 的阶乘 练习 7.22 下列声明的类型是什么 (a) int (*mpf)(vector&); (b) void (*apf[20])(double); (c) void (*(*papf)[2])(int); 怎样用 typedef 名字来使这些声明更容易阅读 练习 7 23 下列函数是在头文件中定义的 C 库函数 double abs(double); double sin(double); double cos(double); double sqrt(double); 怎样声明一个 C 函数指针的数组 并初始化该数组使它包含这四个函数 写一个 main() 函数 使它通过该数组的元素 用实参 97.9 调用 sqrt() 练习 7.24 让我们回到 sort()的例子 已知函数定义 int sizeCompare( const string &, const string & ); 324 第七章 函数 如果指向字符串的两个参数长度相同 则 sizeCompare()返回 0 否则 如果第一个参数 表示的字符串长度比第二个参数的字符串长度短 则返回一个负数 如果大于 则返回一个 正数 记住 字符串操作 size()返回字符串的长度 改变 main()函数 让它用指向 sizeCompare() 的指针作为第三个参数来调用 sort() 8 域和生命期 本章将回答关于在 C++中声明 declaration 的两个重要问题 声明引入的名字可 以被用在什么地方 对一个程序来说 何时使用一个对象或调用一个函数比较安 全 即由声明引入的运行时刻实体的生命期是什么 为回答第一个问题 我们将给 出域的概念 并且介绍它们是怎样界定一个名字在程序文本文件中的可用范围 本 章将介绍各种 C++域 全局域 局部域 而在本章结尾还将介绍一个更为高级的话 题 名字空间域 为回答第二个问题 我们将讲述声明是怎样引入全局对象和函数 在整个程序生存期间一直有效的实体 局部对象 在程序生存期间的子集上有 效的对象 以及动态分配的对象 生命期由程序员控制的对象 的 此外我们还 将查看与这些对象和函数相关的运行时刻特性 8.1 域 C++程序中的每个名字都必须指向惟一的一个实体 对象 函数 类型或模板 这并 不意味着在一个 C++程序中 一个名字只能被使用一次 一个名字可以被重新使用以指向不 同的实体 只要编译器能够根据上下文 context 区分出该名字的不同含义即可 用来区分 名字含义的一般上下文就是域 scope C++支持三种形式的域 局部域 local scope 名字空间域 namespace scope 以及类域 class scope 局部域是包含在函数定义 或者函数块 中的程序文本部分 每一个函数都有一个独立 的局部域 在函数中的每个复合语句 或块 也有一个独立的局部域 名字空间域是不包含在函数声明 函数定义或者类定义内的程序文本部分 程序的最外 层的名字空间域被称作全局域 global scope 或全局名字空间域 global namespace scope 对象 函数 类型以及模板都可以在全局域中定义 程序员也可以利用名字空间定义 namespace definition 来定义用户声明的 user-declared 的名字空间 它们被嵌套在全 局域内 每个用户声明的名字空间都是一个不同的域 它们都与全局域不同 与全局域相同 的是 用户声明的名字空间可以包含对象 函数 类型和模板的声明与定义 以及被嵌套其 内的用户声明的名字空间 用户声明的名字空间见 8.5 节和 8.6 节 每个类定义都引入了一个独立的类域 类定义和类域见第 13 章 326 第八章 域和生命期 同一个名字在不同的域中可以引用不同的实体 例如 在下面的程序段中 有四个实体 被命名为 s1 #include #include // 按字典序比较 s1 和 s2 int lexicoCompare( const string &s1, const string &s2 ) { ... } // 比较 s1 和 s2 的长度 int sizeCompare( const string &s1, const string &s2 ) { ... } typedef int (*PFI)( const string &, const string & ); // 排序字符串数组 void sort( string *s1, string *s2, PFI compare =lexicoCompare ) { ... } string s1[10] = { "a", "light", "drizzle", "was", "falling", "when", "they", "left", "the", "school" }; int main() { // 调用 sort() -用比较的缺省实参 // 调用全局数组 s1 sort( s1, s1 + sizeof(s1)/sizeof(s1[0]) - 1 ); // display the sorted array for ( int i = 0; i < sizeof(s1) / sizeof(s1[0]); ++i ) cout << s1[ i ].c_str() << "\n\t"; } 因为函数 lexicoCompare() sizeCompare()和 sort()定义的域不同 以及这些域都不是全 局域 所以这些域都可以定义一个名为 s1 的变量 由声明引入的名字从声明点直到声明它的域结束为止都是可见的 包含其中的嵌套域 因此 lexicoCompare()的参数名 s1 直到其域的结尾都是可见的 即 到 lexicoCompare()定义 的结束 全局数组 s1 的名字从它的声明点直到文件的结尾都是可见的 包括里面的嵌套域 比如在 main()的定义中 一般来说 在给定的域中 一个名字必须被声明为引用某个实体 例如 如果把下面的 声明加入到前面的例子中 跟在全局域中数组 s1 的声明之后 则会产生一个编译错误 void s1(); // 错误: 重复声明名字 s1 重载函数是此规则的一个例外 在同一个域中我们可以定义不止一个同名的函数 只要 每个函数的参数表不同就可以 第 9 章将讨论重载函数 在 C++中 如果一个名字被用在表达式中 则在使用之前必须先声明它 如果在 main() 函数使用 s1 之前编译器并没有找到它的声明 就会产生一个编译错误 名字解析 name resolution 是把表达式中的一个名字与某一个声明相关联的过程 也是给出这个名字的意义 的过程 这个过程依赖于该名字是如何被使用的 以及使用该名字的域 我们对不同上下文 327 第八章 域和生命期 环境中的名字解析的讨论将贯穿全书 局部域的名字解析将在下一小节中讨论 函数模板定 义中的名字解析将在 10.9 节讨论 类域中的名字解析将在 13 章结束时讨论 类模板定义中 的名字解析将在 16.12 节中讨论 域和名字解析是编译时刻的概念 它们应用在程序文本的某一部分上 这些概念给出了 源文件中的程序文本的意义 编译器根据域规则和名字解析规则解释它所读入的程序文本 8.1.1 局部域 局部域是包含在函数定义 或函数块 中的程序文本区 每一个函数都有一个独立的局 部域 在函数中 每个复合语句 或块 也有它自己的局部域 局部域可以被嵌套 例如 下面的函数定义了两层局部域 它对一个有序的整型 vector 进行二分查找 const int notFound = - 1; // 全局域 int binSearch( const vector &vec, int val ) { // 局部域: 层次 #1 int low = 0; int high = vec.size() - 1; while ( low <= high ) { // 局部域: 层次 #2 int mid = ( low + high ) / 2; if ( val == vec[ mid ] ) return mid; if ( val < vec[ mid ] ) high = mid - 1; else low = mid + 1; } return notFound; // 局部域: 层次 #1 } 第一个局部域是 binSearch()函数体的域 它声明了函数参数 vec 和 val 以及变量 low 和 high 在 binSearch()中的 while 循环定义了一个嵌套的局部域 该嵌套的局部域声明了一个变 量 整型 mid 此外 该嵌套局部域还使用了函数参数 vec 和 val 以及局部变量 high 和 low 全局域包括这两个局部域 它声明了一个整型常量 notFound 为 vec 和 val 的函数参数名属于函数体的第一个局部域 这些名字不能在第一个局部域 中被再次声明 例如 int binSearch( const vector &vec, int val ) { // 局部域: 层次 #1 int val; // 错误: 名字 val 的重复声明无效 // ... 函数参数名可以在 binSearch()的函数体内以及嵌套的 while 循环域中使用 但是函数参 数 vec 和 val 不能用在 binSearch()函数体之外 局部域内的名字解析是这样进行的 首先查找使用该名字的域 如果找到一个声明 则 该名字被解析 如果没有找到 则查找包含该域的域 这个过程会一直继续下去 直到找到 一个声明或已经查找完整个全局域 如果后一种情况发生 即没有找到该名字的声明 则这 个名字的用法将被标记为错误 因为在名字解析期间查找域的顺序由内向外 所以在外围域中的声明被嵌套域中的同名 328 第八章 域和生命期 声明所隐藏 在前面的例子中 如果在全局域中 binSearch()的定义之前声明了变量 low 那 么在 while 循环的嵌套局部域中使用的 low 仍然指向局部的 low 的声明 全局声明会被局部 声明隐藏起来 例如 int low; int binSearch( const vector &vec, int val ) { // low 的局部声明 // 隐藏了全局域中的声明 int low = 0; // ... // low 是局部变量 while ( low <= high ) { // ... } // ... } 有一些语句允许在它的控制结构中定义变量 例如 for 循环允许在它的初始化语句中定 义一个变量 for ( int index = 0; index < vecSize; ++index ) { // index 只在这里可见 if ( vec[ index ] == someValue ) break; } // 错误 index 在这里不可见 if ( index != vecSize ) // 找到的元素 在 for 循环的初始化语句中定义的变量 如 index 只在 for 循环本身的局部域中及其中 的嵌套局部域中可见 这是标准 C++中的情况 在标准 C++之前并非如此 就好像 for 语 句是这样的 // 编译器转换后的表示 { // 不可见的复合语句 int index = 0; for ( ; index < vecSize; ++index ) { // ... } } 这可以防止程序员在循环的局部域之外再次访问控制变量 如果程序员希望通过测试 index 来判断是否找到了这个值 则代码段必须重写如下 int index = 0; for ( ; index < vecSize; ++index ) { // ... } // ok: index 在这里可见 329 第八章 域和生命期 if ( index != vecSize ) // 找到元素 因为在 for 循环的初始化语句中声明的变量是局部于该循环的 所以该变量可以在同 局部域内的其他 for 循环的控制结构中被再次使用 例如 void fooBar( int *ia, int sz ) { for (int i=0; i= LT && tok <= GT); } // ----- lex.C ----- #include "token.h" // ... // ----- token.C ----- #include "token.h" // ... 设计头文件有一些要注意的地方 头文件提供的声明逻辑上应该属于一个组 编译头文 件也需要时间 如果头文件过大 或分散的元素太多 程序员可能会不愿意因为包含它而增 加编译时间开销 为降低编译时间开销 有些 C++实现提供了预编译头文件支持 请查询系 统的 C++实现参考手册 了解怎样从一个普通的 C++头文件创建预编译头文件 如果应用程 序有很大的头文件 则使用预编译头文件而不是普通头文件可以大大降低应用程序的编译时 间 第二个考虑是 头文件不应该含有非 inline 函数或对象的定义 例如 下面的代码表示 的正是这样的定义 因此不应该出现在头文件中 extern int ival = 10; double fica_rate; extern void dummy() {} 虽然 ival 是用 extern 声明的 但是它的显式初始化使得它实际上是个定义 类似的情况 虽然 dummy()显式地声明为 extern 但是空花括号代表该函数的定义 尽管 fica_rate 没有被 显式地初始化 但是因为缺少 extern 因而也被视为 C++中实际的定义 这些定义如果在同 一程序的两个或多个文件中被包含 就会产生重复定义的编译错误 在前面给出的 token.h 头文件中 常量 INLINE 和 inline 函数 is_relational()好像都违反了 这条规则 但是 其实并非如此 虽然它们全是定义 但是符号常量定义以及 inline 函数定 334 第八章 域和生命期 义是特殊的定义 符号常量和 inline 函数可以被定义多次 在程序编译期间 在可能的情况下 符号常量的值会代替该名字的出现 这个替代过程 被称为常量折叠 constant folding 例如 当 INLINE 被用在一个文件中时 编译器用 128 代替名字 INLINE 为了使编译器能够用一个常量值替换它的名字 该常量的定义 它的初 始值 必须在它被使用的文件中可见 冈为这个原因 符号常量可以在同一程序的不同文件 中被定义多次 尽管理想情况下 一个具有初始值的常量可以被包含在多个不同的文件中 但是常量折叠使其变得并不必需 甚至在可执行文件只要出现一次就行 但是 在某些情况下不可能做到符号常量的常量折叠过程 在这样的情况下 最好把常 量的初始化移到某一个程序文本文件中 这可以由显式地声明常量为 extern 来实现 例如 // ----- 头文件 ----- const int buf_chunk = 1024; extern char *const bufp; // ----- 程序文本文件 ----- char *const bufp = new char[buf_chunk]; 虽然 bufp 被声明为 const 但是它的值却无法在编译时刻被计算出来 它的初始化值是 一个要求调用库函数的 new 表达式 如果 bufp 在头文件中被初始化 那么它将在每个包含 它的文件中被定义 这不但浪费了空间 而且可能与程序员的意图不符 符号常量是任何 const 型的对象 当下面的声明被放到一个头文件中 并且由程序的两 个独立的文件包含它时 就会导致链接错误 你知道这是为什么吗 // 喔! 不应该被放在一个头文件中 const char* msg = "?? oops: error: "; 问题出在 msg 不是常量 它是一个指向常量值的非常量指针 常量指针的声明如下 指 针声明的完整讨论见第 3 章 const char *const msg = "?? oops: error: "; 该常量指针的定义可以出现在多个文件中 与符号常量类似的情形也适用于 inline 函数 为使编译器能够在函数被调用的地方 内 联地 展开函数体 它必须能够看到 inline 函数的定义 inline 函数在 7.6 节介绍 因此 如果一个 inline 函数将在多个文件中被用到 那么它必须被定义在头文件中 但是 指定一 个函数为 inline 只是暗示该函数应该被内联 编译器实际上是否内联该函数——程序中的一 般或某些特殊凋用——会随编译器的实现而不同 如果编译器在调用点上没有内联该函数 则编译器会为该函数生成一个定义 放到可执行文件中 如果在多个文件中生成同一函数的 定义 则会产生一个不必要的 过大的可执行文件 如果出现下列情况 多数编译器都会产生警告 一般情况下 这要求打开编译器的警告 模式 1 函数的定义使其根本不可能做成 inline 函数 例如 编译器可能抱怨函数过于复杂而 无法内联 在这种情况下 如果可能 就应重写该函数 否则 去掉 inline 指示符 把函数 定义放到程序文本文件中 2 函数的特殊调用不能被内联 例如 在 C++的最初实现 AT&T(cfront) 中 同 335 第八章 域和生命期 —表达式中的一个 inline 函数的第二次调用就无法被内联 在这种情况下 我们可以把表达 式重新改写成两个独立的 inline 函数调用 在把一个函数声明为 inline 之前 我们必须分析它的运行时刻行为 以确信该函数被内 联对于这部分代码来说确实是必要的 建议把那些天生无法内联的函数不声明为 inline 并 且不放在头文件中 练习 8.3 指出下列语句哪些是声明 哪些是定义 为什么 (a) extern int ix = 1024; (b) int iy; (c) extern void reset( void *p ) { /* ... */ } (d) extern const int *pi; (e) void print( const matrix & ); 练习 8.4 在下列声明和定义中 哪些应被放到头文件中 哪些应被放到程序文本文件中 为什 么 (a) int var; (b) inline bool is_equal( const SmallInt &, const SmallInt & ) { } (c) void putValues( int *arr, int size ); (d) const double pi = 3.1416; (e) extern int total = 255; 8.3 局部对象 在局部域中的变量声明引入了局部对象 local object 有三种局部对象 自动对象 automatic object 寄存器对象 register object 以及局部静态对象 local static object 区分这些对象的是对象所在存储区的属性和生命期 自动对象所在存储区从声明它的函数被 调用时开始 一直到该函数结束为止 寄存器对象是一种自动对象 它支持对其值的快速存 取 局部静态对象的存储区在该程序的整个执行期间一直存在 本节我们将讨论这三种局部 变量的属性 8.3.1 自动对象 自动对象的存储分配发生在定义它的函数被调用时 分配给自动变量的存储区来自于程 序的运行栈 它是函数的活动记录的一部分 自动对象也被称为具有自动存储持续时间 automatic storage duration 或自动范围 automatic extent 未初始化的自动对象包含一 个随机的位模式 是该存储区上次被使用的结果 它的值被称为未指定的 unspecified 在函数结束时 它的活动记录被从运行栈中弹出 与该自动对象相关联的存储区被真正 释放 对象的生命期在函数结束时结束 它包含的任何值都被抛弃 因为与自动对象相关联的存储区在函数结束时被释放 所以应该小心使用自动对象的地 336 第八章 域和生命期 址 自动对象的地址不应该被用作函数的返回值 因为函数一旦结束了 该地址就指向一个 无效的存储区 例如 #include "Matrix.h" Matrix* trouble( Matrix *pm ) { { Matrix res; // 用 pm 做一些事情 // 把结果赋值给 res return &res; // 糟糕! } int main() { Matrix m1; // ... Matrix *mainResult = trouble( &m1 ); // ... } mainResult被设置为自动 Matrix 对象 res 的地址 不幸的是 res 的存储区在 trouble()完 成时被释放 在返回到 main()时 mainResult 指向一个未分配的内存 在本例中 该地址 可能仍然有效 因为我们还没有调用其他函数覆盖掉 trouble()函数的活动记录的部分或全部 所以这样的错误很难检测 在 main()中的后续代码部分使用 mainResult 会产生意想不到的 结果 但是 把 main()的自动变量 m1 的地址传递给函数 trouble()则是安全的 我们可以保证 在 trouble()调用期间 main()的存储区在栈中一直是有效的 因此 ml 的内存区在 trouble() 调用期间都是可被访问的 当一个自动变量的地址被存储在一个生命期长于它的指针时 该指针被称为空悬指针 dangling pointer 这是一个严重的程序员错误 因为它所指的内容是不可预测的 如果 该地址的值正好合适 因此程序就不会产生段错误 该程序可能一直执行到完成 但是给 出的是一个无效的结果 8.3.2 寄存器自动对象 在函数中频繁被使用的自动变量可以用 register 声明 如果可能的话 编译器会把该对 象装载到机器的寄存器中 如果不能够的话 则对象仍位于内存中 出现在循环语句中的数 组索引和指针是寄存器对象的很好例子 for ( register int ix = 0; ix < sz; ++ix ) // ... for (register int *p = array ; p < arraySize; ++p ) // ... 函数参数也可以被声明为寄存器变量 bool find( register int *pm, int val ) { while ( *pm ) if ( *pm++ == val ) return true; 337 第八章 域和生命期 return false; } 如果所选择的变量被频繁使用 则寄存器变量可以提高函数的执行速度 关键字 register 对编译器来说只是一个建议 有些编译器可能忽略该建议 而是使用寄 存器分配算法找出最合适的候选放到机器可用的寄存器中 因为编译器知道运行该程序的机 器的结构 所以它选择寄存器的内容时常常会做出更有意义的决定 8.3.3 静态局部对象 我们也能够在函数定义或者函数定义的复合语句中 声明可在整个程序运行期间一直存 在的局部对象 当一个局部变量的值必须在多个函数调用之间保持有效时 我们不能使用普 通的自动对象 自动对象的值在函数结束时被丢弃 这种情形的一种解决方案是把局部对象声明为 static 静态局部对象具有静态存储持续期 间 static storage duration 或静态范围 static extent 虽然它的值在函数调用之间保持 有效 但是其名字的可视性仍限制在其局部域内 静态局部对象在程序执行到该对象的声明 处时被首次初始化 例如 下面是 gcd()的一个版本 它占用一个静态局部对象来跟踪递归的深 度 #include int traceGcd( int v1, int v2 ) { static int depth = 1; cout << "depth #" << depth++ << endl; if ( v2 == 0 ) { depth = 1; return v1; } return traceGcd( v2, v1%v2 ); } 与静态局部对象 depth 相关联的值在 traceGcd()的调用之间保持有效 初始化只在 traceGcd()首次被调用时执行一次 下面的小程序使用了 traceGcd() #include extern int traceGcd(int, int); int main() { int rslt = traceGcd( 15, 123 ); cout << "gcd of (15,123): " << rslt << endl; return 0; } 编译并运行该程序 产生下列输出 depth #1 depth #2 depth #3 depth #4 gcd of (15,123): 3 338 第八章 域和生命期 未经初始化的静态局部对象会被程序自动初始化为 0 相反 自动对象的值会是任意的 除非它被显式初始化 下面的程序说明了自动和静态局部变量的缺省初始化以及不初始化自 动对象的危险 #include const int iterations = 2; void func() { int value1, value2; // 未初始化 static int depth; // 隐式初始化为 0 if ( depth < iterations ) { ++depth; func(); } else depth = 0; cout << "\nvalue1:\t" << value1; cout << "\tvalue2:\t" << value2; cout << "\tsum:\t" << value1 + value2; } int main() { for ( int ix = 0; ix < iterations; ++ix ) func(); return 0; } 执行后结果如下 value1: 0 value2: 74924 sum: 74924 value1: 0 value2: 68748 sum: 68748 value1: 0 value2: 68756 sum: 68756 value1: 148620 value2: 2350 sum: 150970 value1: 2147479844 value2: 671088640 sum: - 1476398812 value1: 0 value2: 68756 sum: 68756 注意 value1 和 value2 是未经初始化的自动对象 它们的初始值如程序输出所示 完全 是个随机值 因此求和的结果也是不能预测的 但是 即使 depth 没有被初始化 它的值也 会被保证是 0 保证 func()递归地调用它自己两次 8.4 动态分配的对象 全局对象和局部对象的生命期是严格定义的 程序员不能以任何方式改变它们的生命期 但是 有时候需要创建一些生命期能被程序员控制的对象 它们的分配和释放可以根据程序 运行中的操作来决定 例如 有人可能希望只在程序运行中遇到错误时 才分配一个字符串 来包含错误消息的文本 如果程序不只产生一种错误消息 那么分配的字符串的长度会随着 遇到的错误文本的长度而变化 我们无法预先知道应该分配多长的字符串 因为字符串的长 度取决于在程序执行期间遇到的错误种类 第三种对象允许程序员完全控制它的分配与释放 这样的对象被称为动态分配的对象 dynamically allocated object 动态分配的对象被分配在程序的空闲存储区 free store 的 可用内存池中 程序员用 new 表达式创建动态分配的对象 用 delete 表达式结束此类对象的 339 第八章 域和生命期 生命期 动态分配的对象可以是单个对象 也可以是对象的数组 在空闲存储区中分配的数 组的长度可以在运行时刻计算 在本节中 关于动态分配的对象 我们将会了解到三种形式的 new 表达式 一种支持单 个对象的动态分配 另一种支持数组的动态分配 第三种形式被称为定位 new 表达式 placement new expression 当空闲存储区被耗尽时 new 表达式会抛出异常 我们将在 第 11 章进一步讨论异常 在第 15 章中 我们将详细讨论 new 表达式和 delete 表达式的用法 8.4.1 单个对象的动态分配与释放 new表达式是由关键字 new 及其后面的类型指示符构成的 该类型指示符可以是内置类 型或 class 类型 例如 new int; 从空闲存储区分配了一个 int 型的对象 类似地 new iStack; 分配了一个 iStack 类对象 new表达式本身并不是十分有用 我们如何使用被分配的对象呢 空闲存储区的一个特 点是 其中分配的对象没有名字 new 表达式没有返回实际分配的对象 而是返回指向该对 象的指针 对该对象的全部操作都要通过这个指针间接完成 例如 int *pi = new int; 该 new 表达式创建了一个 int 型的对象 由 pi 指向它 在运行时刻从空闲存储区中分配内存 比如通过上面的 new 表达式 我们称之为动态内 存分配 dynamic memory allocation 我们说 pi 指向的内存是被动态分配的 空闲存储区的第二个特点是分配的内存是未初始化的 空闲存储区的内存包含随机的位 模式 它是程序运行前该内存上次被使用留下的结果 测试 it ( *pi == 0 ) 总是会失败 因为由 pi 指向的对象含有随机的位 因此我们建议对用 new 表达式创建的 对象进行初始化 程序员可以按如下方式初始化上个例子中的 int 型对象 int *pi = new int( 0 ); 括号内的常量给出了一个初始值 它被用来初始化 new 表达式创建的对象 因此 pi 指 向一个 int 型的对象 该对象的值为 0 括号中的表达式被称作初始化式 initializer 初始 化式的值不一定是常量 任意的能够被转换成 int 型结果的表达式都是有效的初始化式 new表达式的操作序列如下 从空闲存储区分配对象 然后用括号内的值初始化该对象 为从空闲存储区分配对象 new 表达式调用库操作符 new() 前面的 new 表达式与下列代码 序列大体上等价 int ival = 0; // 创建一个用 0 初始化的 int 对象 int *pi = &ival; // 现在指针指向这个对象 当然 不同的是 pi 指向的对象是由库操作符 new()分配的 位于程序的自由存储区中 类似地 如下语句 340 第八章 域和生命期 iStack *ps = new iStack( 512 ); 创建了一个内含 512 个元素的 iStack 型的对象 在类对象的情况下 括号中的值被传递 给该类相关的构造函数 它在该对象被成功分配之后才被调用 类对象的动态分配将在 15.8 节详细讨论 本节余下部分将集中在内置类型上 到目前为止我们所讨论的 new 表达式有一个问题 很不幸 空闲存储区代表的是有限的 资源 在程序执行的某一个点上 空闲存储区可能会被耗尽 从而导致 new 表达式失败 如 果 new 表达式调用的 new()操作符不能得到要求的内存 通常会抛出一个 bad_alloc 异常 异 常处理将在第 11 章讨论 当指针 pi 所指对象的内存被释放时 它的生命期也随之结束 当 pi 成为 delete 表达式的 操作数时 该内存被释放 例如 delete pi; 释放了 pi 指向的内存 结束了 int 型对象的生命期 通过把 delete 表达式放在程序中的 适当位置上 程序员就可以控制在何时结束对象的生命期 delete 表达式调用库操作符 delete() 把内存还给空闲存储区 因为空闲存储区是有限的资源 所以当我们不再需要已分 配的内存时 就应该马上将其返还给空闲存储区 这是很重要的 看过前面的 delete 表达式 你可能会问 如果 pi 因为某种原因被设置为 0 又会怎么样 呢 代码不应该像这样吗 // 这样做有必要吗 if ( pi != 0 ) delete pi; 答案是不 如果指针操作数被设置为 0 则 C++会保证 delete 表达式不会调用操作符 delete() 没有必要测试其是否为 0 实际上 在多数实现下 如果增加了指针的显式测试 那么该测试实际上会被执行两次 在这里 讨论 pi 的生命期和 pi 指向的对象的生命期之间的区别是很重要的 指针 pi 本 身是个在全局域中声明的全局对象 结果 pi 的存储区在程序开始之前就被分配 且一直保 持到程序结束 这与 pi 指向的对象的生命期不同 后者是在程序执行过程中遇到 new 表达式 时才被创建的 pi 指向的内存是动态分配的 它拥有的对象是动态分配的对象 因此 pi 是 一个全局指针 指向一个动态分配的 int 型对象 当程序运行期间遇到 delete 表达式时 pi 指向的内存就被释放了 但是 指针 pi 的内存及其内容并没有受 delete 表达式的影响 在 delete 表达式之后 pi 被称作空悬指针 即指向无效内存的指针 空悬指针是程序错误的一个根源 它很难被检测到 一个比较好的办法是在指针指向的对象被释放后 将该指针设置为 0 这 样可以清楚地表明该指针不再指向任何对象 delete表达式只能应用在指向的内存是用 new 表达式从空闲存储区分配的指针上 将 delete 表达式应用在指向空闲存储区以外内存的指针上 会使程序运行期间出现未定义的行 为 但是 正如前面看到的 delete 表达式应用在值为 0 的指针 即不指向任何对象的指针 上 不会引起任何麻烦 下面的例子给出了安全的和不安全的 delete 表达式 void f() { int i; string str = "dwarves"; 341 第八章 域和生命期 int *pi = &i; short *ps = 0; double *pd = new double(33); delete str; // 糟糕: "dwarves" 不是动态对象 delete pi; // 糟糕: pi 指向 i, 一个局部对象 delete ps; // 安全 delete pd; // 安全 } 下面三个常见程序错误都与动态内存分配有关 1 应用 delete 表达式失败 使内存无法返回空闲存储区 这被称作内存泄漏 memory leak 2 对同一内存区应用了两次 delete 表达式 这通常发生在两个指针指向同一个动态分配 对象的时候 这是一个很难踉踪的问题 若多个指针指向同一个对象 当通过某一个指针释 放了该对象时就会发生这样的情况 此时 该对象的内存被返回给空闲存储区 然后又被分 配给某个别的对象 接着指向旧对象的第二个指针被释放 新对象也就跟着消失了 3 在对象被释放后读写该对象 这常常会发生 因为 delete 表达式应用的指针没有被设 置为 0 这些操纵动态分配内存的错误比较容易出现 而且难于跟踪和修正 为帮助程序员更好 地管理动态分配的内存 C++库提供了 auto_ptr 类类型的支持 这是下一小节的话题 在那 之后 我们将会看到用 new 和 delete 表达式的第二种形式 动态分配和释放数组 8.4.2 auto_ptr auto_ptr 是 C++标准库提供的类模板 它可以帮助程序员自动管理用 new 表达式动态分 配的单个对象 不幸的是 对用 new 表达式分配的数组管理没有类似的支持 我们不能用 auto_ptr 存储数组 如果这样做了 结果将是未定义的 auto_ptr 对象被初始化为指向由 new 表达式创建的动态分配对象 当 auto_ptr 对象的生 命期结束时 动态分配的对象被自动释放 在本小节中 我们将看看怎样把 auto_ptr 对象与 new 表达式创建的对象关联起来 在使用 anto_ptr 类模板之前 必须包含下面的头文件 #include auto_ptr 对象的定义有下列三种形式 auto_ptr< type_pointed_to > identifier( ptr_allocated_by_new ); auto_ptr< type_pointed_to > identifier( auto_ptr_of_same_type ); auto_ptr< type_pointed_to > identifier; type_pointed_to 代表由 new 表达式创建的对象的类型 我们来依次看一下这些定义 在 最常见的情况下 我们希望把 auto_ptr 直接初始化为 new 表达式返回的对象地址 我们可以 这样做 auto_ptr< int > pi( new int( 1024 ) ); pi被初始化为由 new 表达式创建的对象的地址 且该对象的初始化值为 1024 我们可以 342 第八章 域和生命期 检查 auto_ptr 所指的对象的值 方式与普通指针相同 if ( *pi != 1024 ) // 喔, 出错了 else *pi *= 2; new表达式创建的对象由 pi 指向 当 pi 的生命期结束时 它将被自动释放 如果 pi 是 个局部对象 则 pi 所指的对象在定义 pi 的模块结束时被释放 如果 pi 是全局对象 则 pi 所 指的对象在程序结束时被释放 如果我们用一个 class 类型的对象初始化 auto_ptr 对象 比如标准 string 类型 会怎么样 呢 例如 auto_ptr< string > pstr_auto( new string( "Brontosaurus" ) ); 假设我们现在希望访问一个字符串操作 对于普通的 string 指针 我们会这样做 string *pstr_type = new string( "Brontosaurus" ); if ( pstr_type ->empty() ) // 喔 出错了 那么 怎样用 auto_ptr 对象访问字符串操作 empty()呢 我们将使用相同的方式 auto_ptr< string > pstr_auto( new string( "Brontosaurus" ) ); if ( pstr_auto->empty() ) // 喔 出错了 auto_ptr 类模板背后的主要动机是支持与普通指针类型相同的语法 但是为 auto_ptr 对象 所指对象的释放提供自动管理 根据一般的常识 你可能会认为这种额外的安全性一定来自 于执行效率的开销 但实际情况并不是这样 因为对这些操作的支持都是内联的 它们由编 译器在调用点上展开 所以使用 auto_ptr 对象并不比直接使用指针代价更高 在下面的情况下 我们用 pstr_auto 的值初始化 pstr_auto2 并且 pstr_auto 的底层对象是 string 会怎样呢 // 谁负责 string 的删除操作 auto_ptr< string > pstr_auto2( pstr_auto ); 假定直接用一个 string 指针初始化另一个 比如 string *pstr_type2( pstr_type ); 那么 这两个指针都持有程序空闲存储区内的字符串地址 我们必须小心地将 delete 表 达式只应用在一个指针上 而 auto_ptr 类模板支持所有权概念 当定义 pstr_auto 时 它知道自己对初始化字符串拥有所有权 并且有责任删除该字符串 这是所有权授予 auto_ptr 对象的责任 问题是 当 pstr_auto2 被初始化为指向与 pstr_auto 相同的对象时 所有权会发生什么样 的变化 我们不希望让两个 auto_ptr 对象都拥有同一个底层对象——这会引起重复删除对象 的问题 这也是我们使用 auto_ptr 类型首先要防止的 当一个 auto_ptr 对象被用另一个 auto_ptr 对象初始化或赋值时 左边被赋值或初始化的 对象就拥有了空闲存储区内底层对象的所有权 而右边的 auto_ptr 对象则撤消所有责任 于 是 在我们的例子中 将是用 pstr_auto2 删除字符串对象 向不是 pstr_auto pstr_auto 不再 343 第八章 域和生命期 被用来指向字符串对象 类似的行为也发生在赋值操作符上 已知下列两个 auto_ptr 对象 auto_ptr< int > p1( new int( 1024 ) ); auto_ptr< int > p2( new int( 2048 ) ); 赋值操作符可以将一个 auto_ptr 对象拷贝到另一个中 如下所示 p1 = p2; 在赋值之前 由 p1 指向的对象被删除 赋值之后 p1 拥有 int 型对象的所有权 该对象 值为 2,048 p2 不再被用来指向该对象 在 auto_ptr 定义的第三种形式中 我们创建一个 auto_ptr 对象 但是没有用指针 指向 空闲存储区中对象 将其初始化 例如 // 没有指向任何对象 auto_ptr< int > p_auto_int; 因为 p_auto_int 没有被初始化指向一个对象 所以它的内部指针值被设置为 0 这意味 着对它解除引用会使程序出现未定义的行为 就好像我们直接解引用一个值为 0 的指针时所 发生的一样 // 喔! 解引用一个没有指向任何对象的 auto_ptr if ( *p_auto_int != 1024 ) *p_auto_int = 1024; 对于普通指针 我们只需测试是否为 0 例如 int *pi = 0; if ( pi != 0 ) ...; 但是怎样测试一个 auto_ptr 对象是否指向一个底层对象呢 操作 get()返回 auto_ptr 对象 内部的底层指针 所以 为了判断 auto_ptr 对象是否指向一个对象 我们可以如下编程 // 修改后的测试: 保证 p_auto_int 指向一个对象 if ( p_auto_int.get() != 0 && *p_auto_int != 1024 ) *p_auto_int = 1024; 如果它没有指向一个对象 那么怎样使其指向一个呢——即 怎样设置一个 auto_ptr 对 象的底层指针 我们可以用 reset()操作 例如 else // ok, 让我们设置 p_auto_int 的底层指针 p_auto_int.reset( new int( 1024 ) ); 我们不能够在 auto_ptr 对象被定义之后 再用 new 表达式创建对象的地址来直接向其赋 值 因此 我们不能这样写 void example() { // 缺省, 用 0 初始化 auto_ptr< int > pi; { // 不支持 pi = new int( 5 ); 344 第八章 域和生命期 } } 为了重置一个 auto_ptr 对象 我们必须使用 reset()函数 我们可以向 reset()传递一个指 针 如果不希望设置 或者取消原来的设置 该 auto_ptr 对象的话 可以传进一个 0 值 如 果 auto_ptr 当前指向一个对象并且该 auto_ptr 对象拥有该对象的所有权 则该对象在底层指 针被重置之前 首先被删除 例如 auto_ptr< string > pstr_auto( new string( "Brontosaurus" ) ); // 在重置之前删除对象 Brontosaurus pstr_auto.reset( new string( "Long -neck" ) ); 在这种情况下 用字符串操作 assign()对原有的字符串对象重新赋值 比删除原有的字符 率对象并重新分配第二个字符串对象更为有效 // 这种情况下 重置的更有效形式 // 用 string 的 assign() 设置新值 pstr_auto->assign( "Long-neck" ); 程序设计的一件难事是 有时候仅仅得到正确的结果是不够的 有时候 我们不仅需要 正确的结果 而且还需要一个可接受的性能 像调用 assign()有效释放和重分配一个字符串这 样的小事情就是一个很好的例子 它说明在某些情况下 这些小细节会积聚成可怕的性能瓶 颈 这些细节不应该烦忧那些试图为整个程序提供解决方案的人 但是这些细节是有经验的 程序员应该考虑的 auto_ptr 类模板为动态分配内存提供了大量的安全性和便利 但是 我们仍需小心 否 则我就会陷入麻烦 我们可能会做错些什么呢 1 我们必须小心 不能用一个指向 内存不是通过应用 new 表达式分配的 指针来初 始化或赋值 auto_ptr 如果这样做了 delete 表达式会被应用在不是动态分配的指针上 这将 导致未定义的程序行为 2 我们必须小心 不能让两个 auto_ptr 对象拥有空闲存储区内同一对象的所有权 一种 很显然犯这种错误的方法是 用同一个指针初始化或赋值两个 auto_ptr 对象 另一种途径是 通过使用 get()操作 例如 auto_ptr< string > pstr_auto( new string( "Brontosaurus" ) ); // 喔! 现在两个指针都指向同一个对象 // 并都拥有该对象的所有权 auto_ptr< string > pstr_auto2( pstr_auto.get() ); release()操作允许将一个 auto_ptr 对象的底层对象初始化或赋位给第二个对象 而不会 使两个 auto_ptr 对象同时拥有同一对象的所有权 release()不仅像 get()操作一样返回底层对 象的地址 而且还释放这对象的所有权 前面代码段可被正确改写如下 // ok: 两个对象仍然指向同一个对象 // 但是, pstr_auto 不再拥有拥有权 auto_ptr< string > pstr_auto2( pstr_auto.release() ); 345 第八章 域和生命期 8.4.3 数组的动态分配与释放 new表达式也可以在空闲存储区中分配数组 在这种情况下 new 表达式中的类型指示 符后面必须有一对方括号 里面的维数是数组的长度 且该组数可以是一个复杂的表达式 new 表达式返回指向数组第一个元素的指针 例如 // 分配单个 int 型的对象 // 用 1024 初始化 int *pi = new int( 1024 ); // 分配一个含有 1024 个元素的数组 // 未被初始化 int *pia = new int[ 1024 ]; // 分配一个含 4x1024 个元素的二维数组 int (*pia2)[ 1024 ] = new int[ 4 ][ 1024 ]; pi指向一个 int 型的单个对象 初始值为 1024 pia 指向数组的第十个元素 该数组有 1024 个元素 pia2 指向一个由四个 1024 个元素的数组构成的数组的第一个元素——即 pia2 指向一个有 1024 个元素的数组 一般地 在空闲存储区上分配的数组不能给出初始化值集 在 15.8 节 我们将了解在 空闲存储区分配的类数组怎样用类的缺省构造函数进行初始化 我们不可能在前面的 new 表达式中 通过指定初始值来初始化数组的元素 在空闲存储区中创建的内置类型的数组必 须在 for 循环中被初始化 即数组的元素被一个接一个地初始化 for ( int index = 0; index < 1024; ++index ) pia[ index ] = 0; 动态分配数组的主要好处是 它的第一维不必是常量值 即 在编译时刻不需要知道维 数 就像局部域或全局域中的定义所引入的数组的维数一样 这意味着我们可以分配符合当 前程序所需要大小的内存 例如 在实际的 C++程序中 如果在程序执行期间 一个指针可 能会指向许多个 C 风格的字符串 那么 被用来存放 C 风格字符串的内存 也就是该指针所 指的字符串 通常是在程序执行期间根据字符串的长度动态分配所得 该技术比分配能够 存放所有字符串的固定长度的数组更为有效 因为固定长度的字符串必须足够大 以便能够 存放最大可能的字符串 尽管多数情况下字符串的长度可能都比较短 而且 如果有一个字 符串实例比我们确定的固定长度还要长 则我们的程序就会失败 下面的例子说明了怎样用 new 表达式将数组的第一维指定为运行时刻的一个值 假设有 下列 C 风格的字符串 const char *noerr = "success"; // ... const char *err189 = "Error: a function declaration must " "specify a function return type!"; 由 new 表达式分配的数组的维数可被指定为一个在运行时刻才被计算出来的值 如下所 示 #include 346 第八章 域和生命期 const char *errorTxt; if (errorFound) errorTxt = err189; else errorTxt = noerr; int dimension = strlen( errorTxt ) + 1; char *str1 = new char[ dimension ]; // 将错误文本复制到 str1 strcpy( str1, errorTxt ); 我们也可以用一个在运行时刻才被计算的表达式代替 dimension // 典型的编程习惯 // 有时会让初学者迷惑 char *str1 = new char[ strlen( errorTxt ) + 1 ]; 对 strlen()返回的值加 1 是必需的 这样才能容纳 C 风格字符串的结尾空字符 忘记分配 这个空字符是个常见错误 并且很难跟踪 因为这样的错误通常是在程序的其他部分读写内 存失败时才会表现出来 为什么呢 因为大多数处理 C 风格字符串数组的例程都要遍历数组 直到结尾空字符 缺少该空字符常常导致严重的程序错误 因为程序会读写到其他不该读写 的内存 我们建议使用 C++标准库 string 这正是避免此类错误的一个原因 注意 对于用 new 表达式分配的数组 只有第一维可以用运行时刻计算的表达式来指定 其他维必须是在编译时刻已知的常量值 例如 int getDim(); // 分配一个二维数组 int (*pia3)[ 1024 ] = new int[ getDim() ][ 1024 ]; //ok // 错误: 数组的第二维不是常量 int **pia4 = new int[ 4 ][ getDim() ]; 用来释放数组的 delete 表达式形式如下 delete [] str1; 空的方括号是必需的 它告诉编译器 该指针指向空闲存储区中的数组而不是单个对象 因为 str1 类型是 char 型的指针 所以 如果编译器没有看到空方括号对 它就无法判断出要 被删除的存储区是否为数组 如果不小心忘了该空括号对 会怎么样呢 编译器不会捕捉到这样的错误 并且不保证 程序会正确执行 当数组的类型有析构函数时 这更加会是真的 如 14.4 节所述 为避免动态分配数组的内存管理带来的问题 一般建议使用标准库 vector list 或 string 容器类型 这些类型都会自动管理内存分配 string 类型在 3.4 节介绍 vector 见 3.10 节 容 器类型在第 6 章详细讨论 8.4.4 常量对象的动态分配与释放 程序员可能希望在空闲存储区创建一个对象 但是一旦它被初始化了就要防止程序改变 347 第八章 域和生命期 该对象的值 我们可以使用 new 表达式在空闲存储区内创建一个 const 对象 如下所示 const int *pci = new const int(1024); 在空闲存储区创建的 const 对象有一些特殊的属性 首先 const 对象必须被初始化 如 果省略了括号中的初始值 就会产生编译错误 除此之外 对于具有缺省构造函数的 class 类型的对象 初始值可以省略 第二 用 new 表达式返回的值作为初始值的指针必须是一 个指向 const 类型的指针 在前面的例子中 pci 是一个指向 const int 的指针类型 它指向由 new 表达式分配的 const int 对象 对于一个位于空闲存储区内的对象 const 意味着什么呢 它意味着一旦该对象被初始化 后 它的值就不能再被改变了 虽然该对象的值不能被修改 但是它的生命期也用 delete 表 达式来结束 例如 delete pci; 即使 delete 表达式的操作数是一个指向 const int 的指针 delete 表达式仍然是有效的 并且使 pci 指向的内存被释放 我们不能在空闲存储区内创建内置类型元素的 const 数组 一个简单的原因是 我们不 能初始化用 new 表达式创建的内置类型数组的元素 所有在空闲存储区内被创建的 const 对 象都必须被初始化 而且 因为 const 数组不能被初始化 除了类数组 所以试图用 new 表达式创建一个内置类型的 const 数组会导致编译错误 const int *pci = new const int[100]; // 错误 8.4.5 定位 new 表达式 new表达式的第三种形式可以允许程序员要求将对象创建在已经被分配好的内存中 这 种形式的 new 表达式被称为定位 new 表达式 placement new expression 程序员在 new 表 达式中指定待创建对象所在的内存地址 new 表达式的形式如下 new (place_address) type -specifier place_address 必须是个指针 为了使用这种形式的 new 表达式 我们必须包含头文件 这项设施允许程序员预分配大量的内存 供以后通过这种形式的 new 表达式创建对 象 例如 #include #include const int chunk = 16; class Foo { public: int val() { return _val; } Foo() { _val = 0; } private: int _val; }; // 预分配内存 但没有 Foo 对象 char *buf = new char[ sizeof(Foo) * chunk ]; 348 第八章 域和生命期 int main() { // 在 buf 中创建一个 Foo 对象 Foo *pb = new (buf) Foo; // 检查一个对象是否被放在 buf 中 if ( pb->val() == 0 ) cout << "new expression worked!" << endl; // 到这里不能再使用 pb delete[] buf; return 0; } 编译并执行该程序 产生下列输出 new expression worked 不存在与定位 new 表达式相匹配的 delete 表达式 其实我们并不需要这样的 delete 表达 式 因为定位 new 表达式并不分配内存 在前面的例子中 我们删除的不是指针 pb 指向的 内存 而是 buf 指向的内存 这是必须的 当程序结尾处不冉需要字符缓冲时 buf 指向的内 存被删除 因为 buf 指向一个字符数组 所以 delete 表达式形式为 delete [] buf; 当字符缓冲被删除时 它所包含的任何对象的生命期也就都结束了 在本例中 pb 不再 指向一个有效的 Foo 型的对象 练习 8.5 说明下列 new 表达式错误的原因 (a) const float *pf = new const float[100]; (b) double *pd = new double[10][getDim()]; (c) int (*pia2)[ 1024 ] = new int[ ][ 1024 ]; (d) const int *pci = new const int; 练习 8.6 已知下面的 new 表达式 怎样删除 pa typedef int arr[10]; int *pa = new arr; 练习 8.7 下列 delete 表达式哪些有潜在的运行时刻错误 为什么 int globalObj; char buf[1000]; void f() { int *pi = &globalObj; double *pd = 0; 349 第八章 域和生命期 float *pf = new float(0); int *pa = new(buf)int[20]; delete pi; // (a) delete pd; // (b) delete pf; // (c) delete[] pa; // (d) } 练习 8.8 下列 auto_ptr 声明哪些是非法的或可能引起后续的程序出现错误 解释原因 int ix = 1024; int *pi = & ix; int *pi2 = new int( 2048 ); (a) auto_ptr p0(ix); (b) auto_ptr p1(pi); (c) auto_ptr p2(pi2); (d) auto_ptr p3(&ix); (e) auto_ptr p4(new int(2048)); (f) auto_ptr p5(p2.get()); (g) auto_ptr p6(p2.release()); (h) auto_ptr p7(p2); 练习 8.9 说明下列两条语句的不同之处 int *pi0 = p2.get(); int *pi1 = p2.release(); 在什么情况下 调用哪一个会更合适 练习 8.10 假设有下面的语句 auto_ptr< string > ps( new string( "Daniel" ) ); 下列两个 assign()调用的区别是什么 你认为哪个更合适 为什么 ps.get()->assign( "Danny" ); ps->assign( "Danny" ); 8.5 名字空间定义 缺省情况下 在全局域 也被称作全局名字空间域 global namespace scope 中声明的 每个对象 函数 类型或模板都引入了一个全局实体 global entity 在全局名字空间域引 入的全局实体必须有惟一的名字 例如 函数和对象不能有相同的名字 无论它们是否在同 一程序文本文件中被声明 这意味着 如果我们希望在程序中使用一个库 那么我们必须保证程序中的全局实体的 名字不能与库中的全局实体名字冲突 如果程序是由许多厂商提供的库构成的 那么这将很 难保证 各种库会将许多名字引入到全局名字空间域中 在组合不同厂商的库时 我们该怎 350 第八章 域和生命期 样确保程序中的全局实体的名字不会与这些库中声明的全局实体名冲突 名字冲突问题也被 称为全局名字空间污染 global namespace pollution 问题 程序员可以通过使全局实体名字很长 或与在程序中的名字前面加个特殊的字符序列前 缀 从而避免这些问题 例如 class cplusplus_primer_matrix { ... }; void inverse( cplusplus_primer_matrix & ); 但是 这种方案不是很理想 用 C++写的程序中可能有相当数目的全局类 函数和模板 在整个程序中都是可见的 对程序员来说 用这么长的名字写程序实在是个累赘 名字空间允许我们更好地处理全局名字空间污染问题 库的作者可以定义一个名字空间 从而把库中的名字隐藏在全局名字空间之外 例如 namespace cplusplus_primer { class matrix { /* ... */ }; void inverse ( matrix & ); } 名字空间 cplusplus_primer 是用户声明的名字空间 和全局名字空间不同 后者被隐式声 明 井且存在于每个程序之中 每个用户声明的名字空间代表一个不同的名字空间域 用户声明的名字空间域可以包含 其他嵌套的名字空间定义 以及函数 对象 模板和类型的声明或定义 在一个名字空间内 声明的实体被称为名字空间成员 namespace member 与全局名字空间域的情形一样 用 户声明的名宇空间中的每个名字必须指向该名字空间内的惟一实体 但是 因为不同的用户 声明的名字空间引入了不同的域 所以不同的用户声明的名字空间可以具有相同名字的成员 名字空间成员的名字会自动地与该名字空间名复合或被其限定修饰 qualified 例如 在名字空间 cplusplus_primer 中声明的 matrix 类的名字是 cplusplus_primer::matrix inverse 函 数的名字是 cplusplusprimer::inverse() 在程序中我们可以用限定修饰名来使用名字空间 cplusplus_primer 的成员 如下所示 void func( cplusplus_primer::matrix &m ) { // ... cplusplus_primer::inverse(m); return m; } 如果另一个用户声明的名字空间 如 DisneyFeatureAnimation 也提供了一个 matrix 类 而且我们希望使用这个类 而不是在名字空间 cplusplus_primer 中定义的类 则需要修改 func() 如下所示 void func( DisneyFeatureAnimation::matrix &m ) { // ... DisneyFeatureAnimation::inverse(m); return m; } 当然 总是用限定修饰名来引用名字空间成员会比较麻烦 351 第八章 域和生命期 namespace_name::member_name 因为这个原因 C++提供了一些机制 比如名字空间别名 namespace aliase using 声 明 using declaration using 指示符 using directive 使得在程序中使用名字空间成员更 容易一些 我们将在 8.6 节中展示这些机制 8.5.1 名字空间定义 用户声明的名字空间定义以关键字 namespace 开头 后面是名字空间的名字 该名字在 它被定义的域中必须是惟一的 如果在同样的名字空间域中有其他实体与被定义的名字空间 同名 就会发生错误 当然 这意味着名字空间定义并没有消除全局名字空间污染问题 但 是 使用名字空间大大地缓解了这个问题 在名字空间名之后是由花括号 {} 括起来的声明块 所有可以出现在全局名字空间域 中的声明都可以被放在用户声明的名字空间中 类 变量 带有初始化 函数 带有定义 以及模板 把一个声明放在用户声明的名字空间中并不会改变其意义 惟一的不同是 这样 的声明所引入的名字要与名字空间名复合起来 例如 namespace cplusplus_primer { class matrix { /* ... */ }; void inverse ( matrix & ); matrix operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } const double pi = 3.1416; } 在名字空间 cplusplus_primer 中声明的类的名字是 cplusplus_primer::matrix 函数的名字是 cplusplus_primer::inverse() 常量的名字是 cplusplus_primer::pi 类 函数 常量的名字被声明它的名字空间的名字限定修饰 这些名字被称为限定修饰 名 qualified name 名字空间的定义不一定是连续的 例如 可以如下定义前面的名字空间 namespace cplusplus_primer { class matrix { /* ... */ }; const double pi = 3.1416; } namespace cplusplus_primer { void inverse ( matrix & ); matrix operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } } 前面两个例子是等价的 它们定义的名字空间 cplusplus_primer 都包含类 matrix 函数 352 第八章 域和生命期 inverse() 常量 pi 以及 operator+() 因此 名字空间的定义是可累积的 在下面这一行 namespace namespace_name { 如果 namespace name 没有引用前面已经定义过的名字空间 那么它就会定义一个新的 名字空间 否则 它将打开原来的名字空间 以便加入新的声明 名字空间的定义可以非连续 这对生成一个库很有帮助 它使我们更容易将库的源代码 组织成接口和实现部分 例如 // 名字空间的这部分定义了库接口 namespace cplusplus_primer { class matrix { /* ... */ }; const double pi = 3.1416; matrix operator+ ( const matrix &m1, const matrix &m2 ); void inverse ( matrix & ); } // 名字空间的这部分定义了库实现 namespace cplusplus_primer { void inverse ( matrix &m ) { /* ... */ } matrix operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } } 该名字空间的第一部分给出了描述库接口的声明和定义 类型定义 常量定义 以及函 数声明 该名字空间的第二部分给出了库的详细实现——即 函数定义 对于组织一个库的源代码帮助更大的是 同一个名字空间的定义可以跨越几个不同的程 序文本文件 不同程序文本文件的名字空间定义也可以积累起来 所以 我们的库可以组织 成如下 // ---- primer.h ---- namespace cplusplus_primer { class matrix { /* ... */ }; const double pi = 3.1416; matrix operator+ ( const matrix &m1, const matrix &m2 ); void inverse( matrix & ); } // ---- primer.C ---- #include "primer.h" namespace cplusplus_primer { void inverse( matrix &m ) { /* ... */ } matrix operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } } 353 第八章 域和生命期 使用我们的库的程序可能这样 // ---- user.C ---- // 定义库的接口 #include "primer.h" void func( cplusplus_primer::matrix &m ) { // ... cplusplus_primer::inverse( m ); } 这种程序组织方式使我们的库具有模块化特性 这种特性是 向用户隐藏实现细节 所 必需的 它允许文件 primer.C 和 user.C 被编译链接到一个程序中 而不会有编译错误和链接 错误 8.5.2 域操作符 :: 用户声明的名字空间成员名自动被加上前缀 名字空间名后面加上域操作符 :: 名 字空间成员名由该名字空间名进行限定修饰 使用名字空间成员名 比如 matrix 而不用其名字空间名限定修饰是错误的 编译器不 知道名字 matrix 指的是哪个声明 // 定义库接口 #include "primer.h" // 错误: 不能找到 matrix 的声明 void func( matrix &m ); 名字空间成员的声明被隐藏在其名字空间中 除非我们为编译器指定查找声明的名字空 间 否则编译器将在当前的域及嵌套包含当前域的域中查找该名字的声明 例如 如果前面 程序改写为 // 定义库接口 #include "primer.h" class matrix { /* 用户定义 */ }; // ok: 找到全局 matrix 类型 void func( matrix &m ); 则找到全局域中的类 matrix 的定义 该程序能正确编译 因为名字空间成员 matrix 的声 明被隐藏在名字空间 cplusplus_primer 中 所以名字空间成员的名字与全局域中声明的类名没 有冲突 这就是名字空间能够解决全局名字空间污染问题的原因 名字空间成员的名字不会 被找到 除非用户使用域操作符并指定名字空间名字作为前缀 还有其他一些机制可使名字 空间成员的声明在其外面也成为可见的 这样的机制被称作 using 声明 using declaration 和 using 指示符 using directive 我们将在下一节中介绍它们 注意 域操作符也可以被用来引用全局名字空间的成员 同为全局名字空间没有名字 354 第八章 域和生命期 所以如下符号 ::member_name 指向的是全局名字空间的成员 当全局名字空间的成员被嵌套的局部域中声明的名字隐 藏时 这对引用该名字非常有用 下面的例子是一个计算斐波那契序列的函数 设计这个例子的用意是为了说明域操作符 怎样被用来引用一个被隐藏的全局名字空间成员 变量 max 有两个定义 全局声明表示该序 列的最大值 局部声明表示期望的序列长度 前面曾提到过 函数的参数被放在函数的局部 域中 max 的这两个声明在该函数中都必须被访问到 但是 不加修饰地使用 max 引用的 是局部的声明 为访问全局声明 我们必须使用域操作符::max 下面是实现 #include const int max = 65000; const int lineLength = 12; void fibonacci( int max ) { if ( max < 2 ) return; cout << "0 1 "; int v1 = 0, v2 = 1, cur; for ( int ix = 3; ix <= max; ++ix ) { cur = v1 + v2; if ( cur > ::max ) break; cout << cur << " "; v1 = v2; v2 = cur; if (ix % lineLength == 0) cout << endl; } } 下面的 main()函数使用了这个函数 #include void fibonacci( int ); int main() { cout << "Fibonacci Series: 16\n"; fibonacci( 16 ); return 0; } 编译并执行该程序 产生下面的输出 Fibonacci Series: 16 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 8.5.3 嵌套名字空间 前面曾提到过 用户声明的名字空间可以包含嵌套的名字空间 我们可以用嵌套的名字 空间来进一步改善库中代码的组织结构 例如 // ---- primer.h ---- 355 第八章 域和生命期 namespace cplusplus_primer { // 第一个嵌套域 // 定义了库的 matrix 部分 namespace MatrixLib { class matrix { /* ... */ }; const double pi = 3.1416; matrix operator+ ( const matrix &m1, const matrix &m2 ); void inverse( matrix & ); // ... } // 第二个嵌套域 // 定义了库的 Zoology 部分 namespace AnimalLib { class ZooAnimal { /* ... */ }; class Bear : public ZooAnimal { /* ... */ }; class Raccoon : public Bear { /* ... */ }; // ... } } 名字空间 cplusplus_primer 包含两个嵌套的名字空间 MatrixLib 和 AnimalLib 名字空间 cplusplus_primer 可用来防止库中的名字与用户程序中全局名字空间中的名字 发生冲突 这个库也被嵌套的域组织成更小的包 将有关的声明和定义分成组 名字空间 MatrixLib 包含 primer 库中的 matrix 部分 而 AnimalLib 含有库中的 ZooAnimal 部分 嵌套名字空间的成员声明被隐藏在该嵌套域中 这样的成员会被自动地加上最外层名字 空间名以及嵌套名字空间名形成的前缀 例如 在嵌套名字空间 MatrixLib 中声明的类的名 字是 cplusplus_primer::MatrixLib::matrix 函数的名字是 cplusplus_primer::MatrixLib::inverse 程序可以按如下方式使用嵌套名字空间 cplusplus_primer::MatrixLib 的成员 #include "primer.h" // 是的, 这很可怕 // 我们很快会引入使名字空间成员更易于使用的机制 void func( cplusplus_primer::MatrixLib::matrix &m ) { // ... cplusplus_primer::MatrixLib::inverse( m ); } 嵌套名字空间是包含它的名字空间中的一个嵌套域 在名字解析期间 嵌套名字空间的 行为与嵌套块的类似 例如 当一个名字被用在一个名字空间的定义中时 编译器将会在包 含其外的名字空间中查找声明 在下面的例子中 当查找 Type 的声明时 将考虑 Type 被使 用之前的声明 在名字空间 MatrixLib 中的声明被首先考虑 然后再考虑名字空间 cplusplus_primer 中的 最后考虑的是全局域中的声明 typedef double Type; 356 第八章 域和生命期 namespace cplusplus_primer { typedef int Type; // 隐藏 ::Type namespace MatrixLib { int val; // Type: 找到 cplusplus_primer 中的声明 int func(Type t) { double val; // 隐藏 MatrixLib::val val = ...; } // ... } } 在外围名字空间中声明的实体被嵌套的名字空间中声明的同名实体所隐藏 在前面的例 子中 在全局域中的 Type 声明被名字空间 cplusplus_primer 中的 Type 声明隐藏 当在 MatrixLib 名字空间中使用的名字 Type 被解析时 找到在名字空间 cplusplus_primer 中的声明 func()被声明为带一个 int 型的参数 类似的情况 在名字空间中声明的实体被局部域中声明的实体所隐藏 在上一个例子中 在名字空间 MatrixLib 中的 val 的声明被函数 func()局部域中的 val 声明所隐藏 当解析 func() 中用到的名字 val 时 编译器会找到局部域中的声明 在 func()中的赋值是针对这个局部变量 的 8.5.4 名字空间成员定义 我们已经看到 名字空间成员的定义可以出现在名字空间定义内 例如 类 matrix 和常 量 pi 是在嵌套的名字空间 MatrixLib 的定义内被定义的 而函数 operator+()和 inverse()的定 义则是在程序的后面某个地方给出的 // ---- primer.h ---- namespace cplusplus_primer { // 第一个嵌套域 // 定义了库的 matrix 部分 namespace MatrixLib { class matrix { /* ... */ }; const double pi = 3.1416; matrix operator+ ( const matrix &m1, const matrix &m2 ); void inverse( matrix & ); // ... } } 我们也可以在名字空间定义之外定义名字空间成员 在这种情况下 名字空间成员的名 字必须被外围名字空间名限定修饰 例如 函数 operator+()可以在全局域中如下定义 // ---- primer.C ---- #include "primer.h" // 全局域定义 cplusplus_primer::MatrixLib::matrix357 第八章 域和生命期 cplusplus_primer::MatrixLib::operator+ ( const matrix& m1, const matrix &m2 ) { /* ... */ } 在该定义中 名字 operator+()由名字空间 cplusplus_primer 和 MatrixLib 的名字限定修饰 但是 看一下 operator+()参数表中的类型 matrix 的用法 所用的名字没有被嵌套的名字空间 名 cplusplus_primer::MatrixLib 限定修饰 怎么会这样呢 operator+()的定义可以使用名字空间成员名的简短形式 这是因为名字空间成员定义是 在其名字空间的域内 当 operator+()定义中用到的名字被解析时 编译器会考虑名字空间 MatrixLib 中的成员 但是请注意 返回类型必须被限定修饰 这是因为返回类型不在函数定 义的域内 名字空间成员的简短形式只能用在下列成员名之后 cplusplus_primer::MatrixLib::operator+ 在 operator+()的参数表和函数体中 任何声明或表达式中都可以使用简短形式的名字 空间成员名 例如 在 operator+()的局部声明中 我们可以如下创建一个 matrix 类型的对 象 // ---- primer.C ---- #include "primer.h" cplusplus_primer::MatrixLib::matrix cplusplus_primer::MatrixLib::operator+ ( const matrix &m1, const matrix &m2 ) { // 声明一个类型为 cplusplus_primer::MatrixLib::matrix 的局部变量 matrix res; // calculate the sum of two matrix objects return res; } 虽然名字空间成员可以被定义在名字空间定义之外 但是 对于哪些地方可以出现这样 的定义还是有限制的 只有包围该成员声明的名字空间20才可能包含它的定义 例如 operator+()可在全局域 名字空间 cplusplus_primer 或名字空间 MatrixLib 中定义 而且只有 这三种可能 名字空间 cplusplus_primer 中的定义可以是 // ---- primer.C ---- #include "primer.h" namespace cplusplus_primer { MatrixLib::matrix MatrixLib::operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } } 注意 只有当一个名字空间成员在名字空间定义中已经被声明过 它才能在该名字空间 定义之外被定义 如果下面的声明没有出现在 primer.h 中 那么刚才给出的 operator+()的定 义将是一个错误 namespace cplusplus_primer { 20 也就是该成员声明所在的名字空间及其外围名字空间 358 第八章 域和生命期 namespace MatrixLib { class matrix { /* ... */ }; // 下列声明不能被省略 matrix operator+ ( const matrix &m1, const matrix &m2 ); // ... } } 8.5.5 ODR 和名字空间成员 正如前面所提到的 名字空间的定义可以是不连续的 可以跨越多个文件 因此 一个 名字空间可以在多个文件中被声明 例如 // primer.h namespace cplusplus_primer { // ... void inverse( matrix & ); } // use1.C #include "primer.h" // 在 use1.C 中声明 cplusplus_primer::inverse() // use2.C #include "primer.h" // 在 use2.C 中声明 cplusplus_primer::inverse() 通过 use1.C 中的头文件 primer.h 声明的成员 cplusplus::inverse() 与 use2.C 中头文件 primer.h 声明的成员 cplusplus::inverse()引用到同一个函数 虽然名字空间成员名是被限定修饰的 但是名字空间成员也是一个全局实体 还记得 8.2 节讨论的 ODR 要求吗 即非 inline 函数和对象在一个程序中只能被定义一次 这也同样适用 于名字空间成员 为了符合这样的要求 使用名字空间的程序一般组织如下 1 作为名字空间成员的函数和对象的声明被放在头文件中 该文件将被包含在要使用该 名字空间的文件中 // ---- primer.h ---- namespace cplusplus_primer { class matrix { /* ... */ }; // 函数声明 extern matrix operator+ ( const matrix &m1, const matrix &m2 ); extern void inverse( matrix & ); // 对象声明 extern bool error_state; } 2 这些成员的定义可以出现在某一个实现文件中 // ---- primer.C ---- #include "primer.h" namespace cplusplus_primer { // 函数声明 359 第八章 域和生命期 void inverse( matrix & ) { /* ... */ } matrix operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } // 对象声明 bool error_state = false; } 与全局域中的对象声明一样 我们必须用关键字 extern 来指明只是声明名字空间成员 而不是定义它们 关键字 extern 也可以被用在名字空间成员函数的声明中 但是 如同全局 函数的情形一样 在这个例子中 是否使用关键字 extern 是可选的 8.5.6 未命名的名字空间 我们或许希望所定义的对象 函数 类类型或其他实体 它只在程序的一小段代码中可 见 因为这样可以更进一步地缓解名字空间污染问题 因为我们知道该实体只被用在很有限 的地方 所以可能不想再花费太多努力来保证这个实体有惟一的名字而不会与程序其他地方 声明的名字冲突 当我们在一个函数或嵌套块中声明一个对象时 由该声明引入的名字只在 声明它的块中可见 但是 如果程序员想让一个实体被多个函数使用 而又不想让该名字在 整个程序中可用 又该怎么办呢 例如 假设我们想实现一组排序函数 对 double 型 vector 的元素进行排序 // ----- SortLib.h ----- void quickSort( double *, double * ); void bubbleSort( double *, double * ); void mergeSort( double *, double * ); void heapSort( double *, double * ); 所有函数都使用同一个 swap()函数来交换 vector 中的元素 但是 我们不想让 swap()在 整个程序中可见 我们希望保持该函数对于文件 SortLib.C 的局部性 因为只有上面四个函数 调用 swap() 下面的代码没有给我们预期的结果 你能看出为什么吗 // ----- SortLib.C ----- void swap( double *d1, double *d2 ) { /* ... */ } // 只有下面四个函数使用 swap() void quickSort( double *d1, double *d2 ) { /* ... */ } void bubbleSort( double *d1, double *d2 ) { /* ... */ } void mergeSort( double *d1, double *d2 ) { /* ... */ } void heapSort( double *d1, double *d2 ) { /* ... */ } 即使函数 swap()在 SortLib.C 中定义 并且没有在描述排序库的接口的头文件 SortLib.h 中引入 但是 函数 swap()仍然是在全局域中声明的 因此 它是一个全局实体 它的名字 不能与任何其他全局实体的名字冲突 在 C++中 我们可以用未命名的名字空间 unnamed namespace 声明一个局部于某一文 件的实体 未命名的名字空间以关键字 namespace 开头 同为该名字空间是没有名字的 所 以在关键字 namespace 后面没有名字 而在关键字 namespace 后面使用花括号包含声明块 例如 360 第八章 域和生命期 // ----- SortLib.C ----- namespace { void swap( double *d1, double *d2 ) { /* ... */ } } // 上面四个排序函数的定义 函数 swap()只在文件 SortLib.C 中可见 如果另一个文件也含有一个带有函数 swap()定义 的未命名名字空间 则该定义引入的是一个不同的函数 函数 swap()存在两种定义但这并不 是个错误 因为它们是不同的函数 不像其他名字空间 未命名的名字空间的定义局部于一 个特定的文件 不能跨越多个文本文件 在 SortLib.C 中 在未命名的名字空间的定义之后 我们可以用 swap()的简短格式引用它 没有必要用域操作符引用未命名名字空间的成员 void quickSort( double *d1, double *d2 ) { // ... double* elem = d1; // ... // 引用未命名名字空间成员 swap() swap( d1, elem ); // ... } 由于未命名名字空间的成员是程序实体 所以函数 swap()可以在程序整个执行期间被调 用 但是 未命名名字空间成员名只在特定的文件中可见 在构成程序的其他文件中是不可 见的 在引入标准 C++名字空间之前 解决此类声明局部化问题的常见方案是使用从 C 语言中 继承来的关键字 static 未命名名字空间的成员与被声明为 static 的全局实体具有类似的特性 在 C 中 被声明为 static 的全局实体在声明它的文件之外是不可见的 例如 在 SortLib.C 中 的声明可以按如下形式写成 C 程序 它会提供给 swap()相同的特性 // SortLib.C // swap() 在其他程序中不可见 static void swap( double *d1, double *d2 ) { /* ... */ } // sort 函数定义同前 许多 C++实现都支持全局静态声明 但是 随着越来越多的 C++实现都支持名字空间 全局静态声明的用法将会被未命名的名字空间成员所取代 练习 8.11 为什么要在程序中定义自己的名字空间 练习 8.12 假设有下列 operator*()的声明 它是嵌套的名字空间 cplusplus_primer::MatrixLib 的成员 namespace cplusplus_primer { namespace MatrixLib { class matrix { /* ... */ }; matrix operator* ( const matrix &, const matrix & ); 361 第八章 域和生命期 // ... } } 怎样在全局域中定义该操作符 请为该操作符定义提供一个原型 练习 8.13 说明在程序中使用未命名名字空间的原因 8.6 使用名字空间成员 总用限定修饰的名字形式 namespace_name::member_name 来引用名字空间成员 毫无疑 问是非常麻烦的 尤其是当名字空间名很长的时候 如果不得不一直使用限定修饰名 我们 可能会希望创建一些短名字的名字空间 不但因为它们易读 而且因为它们易于键入 但是 使用短的名字空间名会增加与程序中的其他全局名冲突的可能性 所以用长的名字空间名来 发行我们的库更为合适一些 幸运的是 有一些机制能够简化程序中的名字空间成员的用法 名字空间别名 using 声明 using 指示符是帮助我们克服名字空间名使用上的这些不便之处的机制 8.6.1 名字空间别名 名字空间别名 namespace alias 可以用来把一个较短的同义词与一个名字空间名关联 起来 例如 长名字空间名如 namespace International_Business_Machines { /* ... */ } 可以与一个较短的同义词相关联 如下 namespace IBM = International_Business_Machines; 名字空间别名的声明以关键字 namespace 开头 后面是一个较短的别名 然后是赋值操 作符 最后是原来的名字空间名 如果原来的名字空间名不是一个已知的名字空间名 则会 出现错误 名字空间别名也可以指向一个嵌套的名字空间 还记得早先介绍的 func()的可怕定义 吗 如下所示 #include "primer.h" // 很难读 void func( cplusplus_primer::MatrixLib::matrix &m ) { // ... cplusplus_primer:: MatrixLib::inverse( m ); } 利用名字空间别名也可以引用嵌套的名字空间 cplusplusprimer::MatrixLib 从而使该定 义更易读 362 第八章 域和生命期 #include "primer.h" // 短别名 namespace mlib = cplusplus_primer::MatrixLib; // 较易读 void func( mlib::matrix &m ) { // ... mlib::inverse( m ); } 一个名字空间可以有许多同义词或别名 且所有别名和原来的名字空间名都可以交替使 用 例如 假设别名 Lib 指向名字空间名 cplusplus_primer 则 func()的定义可以重写成下面 的形式 它的意义不会改变 // alias 指向名字空间 cplusplus_primer namespace alias = Lib; void func( Lib::matrix &m ) { // ... alias::inverse( m ); } 8.6.2 using 声明 通过使名字空间成员的名字可见 来在程序中用该名字的非限定修饰方式引用这个成员 而不用前缀 namespace_name::name 也是可行的 如果该成员被用 using 指示符声明 那么 这就能够做到这一点 using声明以关键字 using 开头 后面是名字空间成员名 using 声明中的成员名必须是 限定修饰名 例如 namespace cplusplus_primer { namespace MatrixLib { class matrix { /* ... */ }; // ... } } // 名字空间成员 matrix 的 using 声明 using cplusplus_primer::MatrixLib::matrix; using声明在声明出现的域中引入了一个名字 例如 前面的 using 声明向全局域引入了 名字 matrix 在遇到 using 声明之后 在全局域中或其嵌套的域中使用 matrix 都将引用该名 字空间成员 例如 假设 using 声明后而又有下面的声明 void func( matrix &m ); 该声明声明了函数 func() 它有一个参数 类型是 cplusplus_primer::Matrix::matrix using声明同其他声明的行为一样 它有一个域 它引入的名字从该声明开始直到其所 在的域结束都是可见的 using 声明可以出现在全局域和任意名字空间中 同时它也可以出现 在局部域中 与其他声明一样 using 声明引入的名字有以下特性 363 第八章 域和生命期 它在该域中必须惟一 由外围域中的声明引入的相同名字被其隐藏 它被嵌套域中的相同名字的声明隐藏 例如 namespace blip { int bi = 16, bj = 15, bk = 23; // 其他声明 } int bj = 0; void manip() { using blip::bi; // 函数 manip() 中的 bi 指向 blip::bi ++bi; // 设置 blip::bi 为 17 using blip::bj; // 隐藏全局域中的 bj // 在函数 manip() 中的 bj 指向 blip::bj ++bj; // 设置 blip::bj 为 16 int bk; // bk 在局部域中声明 using blip::bk; // 错误: 在 manip() 中重复定义 bk } int wrongInit = bk; // 错误: bk 在这里不可见 应该用 blip::bk 函数 manip()中的 using 声明以简短形式引用名字空间 blip 的成员 using 声明在 manip() 函数之外并不可见 用户只能在 manip()函数内部使用这些短名字 在该函数之外 仍然必须 使用限定修饰名 using 声明使名字空间成员易于使用 using 声明一次只能引入一个名字空间成员 它允 许我们专门指定在程序中要使用的名字 在特定域中引入 using 声明 使我们可以明确地指 定在哪些地方可使用名字空间成员的简短形式 在下一小节 我们将了解怎样一次引入一个 名字空间的全部成员名 8.6.3 using 指示符 名字空间是随标准 C++而引入的 标准 C++之前的实现并不支持名字空间 结果是 标准 C++之前的库也不把全局声明包装在名字空间中 在各种 C++实现支持名字空间之前 人们已经编写了大量重要的 C++代码及其应用程序 如果我们把一个库的内容封装到一个 名字空间内 那么我们也就潜在地打破了这些使用旧版本库的旧版应用程序 如果我们把 该库的内容包装到一个名字空间中 则该库中的所有名字都变成被限定修饰的 即以该名 字空间名加上域操作符作为前缀 而所有以短形式使用该库中的名字的应用程序都不奏效 了 我们可以使用 using 声明使库中的名字变成可见的 例如 假设文件 primer.h 含有该库 的新版本 它将全局声明包装到名字空间 cplusplus_primer 中 如果我们想让自己的程序能很 快地和新库协同工作 那么就可以用两个 using 声明使名字空间 cplusplus_primer 中的类 matrix 和函数 func()的名字变成可见的 364 第八章 域和生命期 #include "primer.h" using cplusplus_primer::matrix; using cplusplus_primer::inverse; // 因为 using 声明 名字 matrix 和 inverse 可以不加限定修饰地被使用 void func( matrix &m ) { // ... inverse( m ); } 如果库非常大 且应用程序使用了库中许多的名字 则翻新一个使用名字空间库的新版 本就可能需要使用大量的 using 声明 而且所有必需的 using 声明只是允许旧代码能像以前一 样编译运行 这样的工作非常乏味 且容易出错 using 指示符可以用来解决这个问题 使得 第一次转换到使用名字空间的库版本更加容易 using指示符以关键字 using 开头 后面是关键字 namespace 然后是名字空间名 如果 该名字没有指向一个前面已经定义的名字空间 则这是一个错误 using 指示符允许我们让来 自特定名字空间的所有名字的简短形式都可见 这些成员可以被直接使用 而不要求其名字 被限定修饰 例如前面的代码例子可以重写如下 #include "primer.h" // using 指示符: cplusplus_primer 的所有成员都变成可见的 using namespace cplusplus_primer; // 名字 matrix 和 inverse 可以不加限定修饰地被使用 void func( matrix &m ) { // ... inverse( m ); } using指示符使名字空间成员名可见 就好像它们是在名字空间被定义的地方之外被声明 的一样 例如 由于 using 指示符 名字空间 cplusplus_primer 的成员就好像是在全局域中 func() 定义之前声明的一样 using 指示符并没有为名字空间成员的名字声明局部的别名 而是把名 字空间的成员转移到包含该名字空间定义的那个域中 比如如下代码 namespace A { int i, j; } 对域中有如下 using 声明的代码来说 using namespace A; 看起来就像 int i, j; 我们来看个例子 它说明了 using 声明的影响 它保留了该名字空间域 但是将成员名与 一个局部同义词相关联 以及 using 指示符的影响 其效果相当于去掉了该名字空间 namespace blip { int bi = 16, bj = 15, bk = 23; // 其他声明 365 第八章 域和生命期 } int bj = 0; void manip() { using namespace blip; // using 指示符 - // ::bj 和 blip::bj 之间的冲突只在 bj 被使用时才被检测到 ++bi; // 设置 blip::bi 为 17 ++bj; // 错误: 二义性 // 全局 bj 还是 blip::bj? ++::bj; // ok: 设置全局 bj 为 1 ++blip::bj; // ok: 设置 blip::bj 为 16 int bk = 97; // 局部 bk 隐藏 blip::bk ++bk; // 设置局部 bk 为 98 } 应该注意的第一个问题是 using 指示符是域内的 在 manip()中的 using 指示符只能应用 在函数 manip()的块内 对函数 manip()来说 名字空间 blip 的成员就好像是在全局域中声明 的一样 所以 函数 manip()可以以简短形式引用这些成员的名字 在函数 manip()之外的代 码必须使用限定修饰名 要注意的第二个问题是 由 using 指示符引起的二义性错误是在该名字被使用时才被检 测到 而不是在遇到 using 指示符时 例如 成员 bj 在 manip()中出现 就好像它是在名字空 间 blip 被定义的地方之外 即全局域中 被声明的一样 然而 在全局域中已经有一个名为 bj 的变量 因此 在函数 manip()中使用 bj 有二义性 该名字同时引用全局变量和名字空间 blio 的成员 但是 using 指示符并没有错 只有当 manip()函数用到 bj 时 编译器才会检测 到二义性错误 如果 bj 在 manip()中没有被用到 则不会产生错误 要注意的第三个问题是 使用限定修饰名不受 using 指示符的影响 当 manip()引用::bj 时 只有全局域中的变量才被考虑 当 manip()引用 blip::bj 时 只考虑名字空间 blip 引入的 变量 要注意的最后一个问题是 因为名字空间成员就好像是在 该名字空间定义所在的地方 at the location where the namespace definition is located 之外被声明的一样 所以出现在 manip()中的成员就好像是在全局域中被定义的一样 这意味着 manip()中的局部声明可以隐 藏某些名字空间成员名 局部变量 bk 隐藏了名字空间成员 blip::bk 在 manip()中引用 bk 没 有二义性 它引用的是局部变量 bk using指示符用起来很简单 只需要使用一个 using 指示符 所有的名字空间成员一下子 就都可见了 尽管这可以看作是一个简单的解决方案 仍是 过多地使用 using 指示符可能 会引入其自身的问题 如果一个应用使用了许多库 且这些库中的名字都用 using 指示符变 为可见 则我们可能又回到了原来的问题 全局名字空间污染问题 例如 namespace cplusplus_primer { class matrix { }; // 其他省略 } namespace DisneyFeatureAnimation { 366 第八章 域和生命期 class matrix { }; // 省略 } using namespace cplus plus_primer; using namespace DisneyFeatureAnimation; matrix m; // 错误: 二义性 // cplusplus_primer 的还是 DisneyFeatureAnimation 的? 由多个 using 指示符引起的二义性错误只能在使用点上被检测到 在前面的例子中 二 义性错误只在 matrix 被使用时才被检测到 这种迟到的检测可能会使用户吃惊 即使头文件 没有被改变 且没有新的声明被加入到程序中 以后仍然可能会出现错误 该错误经常出现 在我们突然决定要使用库中的新特性的时候 当我们把一个应用程序移植到一个包装在名字空间中的新库版本时 using 指示符非常有 用 但是使用多个 using 指示符会引起全局名字空间污染问题 用多个选择性的 using 声明来 代替 using 指示符会使这个问题最小化 由多个选择性的 using 声明引起的二义性错误在声明 点就能被检测到 因此建议使用 using 声明而不是 using 指示符 以便更好地控制程序中的全 局名字空间污染问题 8.6.4 标准名字空间 std 标准 C++库中的所有组件都是在一个被称为 std 的名字空间中声明和定义的 在标准头 文件 如 中声明的函数 对象和类模板 都被声明在名字空间 std 中 如果所有的库组件都在名字空间 std 中被声明 那么下面这个来自 6.5 节中的例子所有的 库组件的名字又会有什么错误 #include #include #include int main() { // 与标准输出绑定的输入流迭代器 istream_iterator infile( cin ); // 标记了 "流结束" 的输入流迭代器 istream_iterator eos; // 用 cin 输入的值初始化 svec vector svec( infile, eos ); // 处理 svec } 对 代码没有通过编译 因为在上面的代码中 名字空间 std 的成员不能被不加限定修 饰地访问 为了修正这个错误 我们可以选择下列方案之一 用适当的限定修饰名代替例子中的名字空间 std 成员的名字 用 using 声明使例子中用到的名字空间 std 的成员可见 用 using 指示符使来自名字空间 std 的全部成员可见 367 第八章 域和生命期 在例子中用到的名字空间 std 的成员有 类模板 iostream_iterator 程序的标准输入 cin 类 string 以及类模板 vector 最简单的解决办案是在#include 指示符后面加上 using 指示符如下 using namespace std; 该 using 指示符使名字空间 std 中的全部成员在例子中都可见 但是 在名字空间 std 中 有太多的声明 我们更喜欢用 using 声明来减少 当我们向程序中增加新的全局声明时发生 名字冲突的可能性 为使程序通过编译 我们只需下列 using 声明 using std::istream_iterator; using std::string; using std::cin; using std::vector; 但是 应该把它放在哪儿呢 如果程序是由许多文件构成的 则创建一个头文件 使它 包含该应用程序所需的名字空间 std 成员的全部 using 声明 这样比较方便 该头文件将被包 含在程序文本文件中的 C++标准库头文件之后 在本书中 为使代码例子简短 且因为许多例子程序都是在不支持名字空间的编译器中 被编译的 所以我们并没有显式地列出需要编译该例子的 using 声明 只是假设在代码例子 中都已经提供了所用到的名字空间 std 成员的 using 声明 练习 8.14 解释 using 声明和 using 指示符的区别 练习 8.15 根据 6.14 节给出的完整例子 写出使名字空间 std 的成员在例子中可见所需要的 using 声明 练习 8.16 考虑下面的代码例子 namespace Exercise { int ivar = 0; double dvar = 0; const int limit = 1000; } int ivar = 0; //1 void manip() { //2 double dvar = 3.1416; int iobj = limit + 1; 368 第八章 域和生命期 ++ivar; ++::ivar; } 如果将名字空间 Exercise 成员的 using 声明放在 //1 处 那么会对代码中的声明和表达式 有什么样的影响 如果放在 //2 处呢 当用 using 指示符代替名字空间 Exercise 的 using 声明 时 答案又是什么 9 重 载 函 数 我们已经知道怎样声明和定义函数 以及怎样在程序中使用函数 在本章中我们将 了解 C++支持的一种特殊函数 重载函数 如果两个函数名字相同 并且在相同的 域中被声明 但是参数表不同 则它们就是重载函数 overloaded function 在本 章中 我们将首先了解怎样声明一组重载函数 以及这样做的好处 然后 再看看 函数重载解析过程是怎样进行的——即 一个函数调用怎样被解析为一组重载函数 中的某一个函数 函数重载解析过程是 C++中最复杂的内容之一 本章的结尾将为 那些希望进一步详细了解重载函数的人提供了两个小节的高级主题 它们将更完整 地描述参数类型转换和函数重载的解析 9.1 重载函数声明 我们已经知道怎样声明和定义函数 以及怎样在程序中使用函数 现在我们将了解 C++ 支持的另一种函数新特性 重载函数 函数重载 function overloading 允许多个函数共享同 一个函数名 但是针对不同参数类型提供共同的操作 如果你曾经用一种程序设计语言写过算术表达式 那么你就已经使用过预定义的重载函 数 例如 如下表达式 1 + 3 调用了针对整数操作数的加法操作 而表达式 1.0 + 3.0 调用了另外一个专门处理浮点操作数的不同的加法操作 实际被使用的操作对用户而言 是透明的 加法操作被重载 以便处理不同的操作数类型 根据操作数的类型来区分不同的 操作并应用适当的操作 是编译器的责任 而不是程序员的事情 本章我们将了解怎样定义自己的重载函数 9.1.1 为什么要重载一个函数名 正如内置加法操作的情形一样 我们可能希望定义一组函数 它们执行同样的一般性动 370 第九章 重载函数 作 但是应用在不同的参数类型上 例如 假设我们希望定义一个函数 它返回参数中的最 大值 如果没有重载一个函数名的能力 那么我们就必须为每个函数给出一个惟一的名字 例 如 我们可能如下定义一组 max()函数 int i_max( int, int ); int vi_max( const vector & ); int matrix_max( const matrix & ); 但是 这些函数都执行了相同的一般性动作 都返回参数集合中的最大值 从用户的角 度来看 只有一种操作 就是判断最大值 至于怎样完成其细节 函数的用户一点也不关心 这种词汇上的复杂性不是 判断一组数中最大值 问题本身固有的 而是反映了程序设 计环境的一种局限性 在同一个域中出现的名字必须指向一个唯实体 惟一的对象 函数 class 类型等等 这种复杂性给程序员带来了一个实际问题 他们必须记住或查找每一个名 字 函数重载把程序员从这种词汇复杂性中解放出来 通过函数重载 程序员可以简单地这样写 int ix = max( j, k ); vector vec; // ... int iy = max( vec ); 这项技术可以获得各种条件下的最大值 9.1.2 怎样重载一个函数名 在 C++中 可以为两个或多个函数提供相同的名字 只要它们的每个参数表惟一就行 或者是参数的个数不同 或者是参数类型不同 下面是重载函数 max()的声明 int max( int, int ); int max( const vector & ); int max( const matrix & ); 参数集惟一的每个重载声明都要求一个独立的 max()定义 当一个函数名在一个特殊的域中被声明多次时 编译器按如下步骤解释第二个 以及后 续的 的声明 如果两个函数的参数表中参数的个数或类型不同 则认为这两个函数是重载的 例 如 // 重载函数 void print( const string & ); void print( vector & ); 如果两个函数的返回类型和参数表精确匹配 则第二个声明被视为第一个的重复声 明 例如 // 声明同一个函数 void print( const string &str ); void print( cons t string & ); 371 第九章 重载函数 参数表的比较过程与参数名无关 如果两个函数的参数表相同 但是返回类型不同 则第一个声明被视为第一个的错 误重复声明 会被标记为编译错误 例如 unsigned int max( int i1, int i2 ); int max( int , int ); // 错误: 只有返回类型不同 函数的返回类型不足以区分两个重载函数 如果在两个函数的参数表中 只有缺省实参不同 则第二个声明被视为第一个的重 复声明 例如 // 声明同一函数 int max( int *ia, int sz ); int max( int *, int = 10 ); typedef 名为现有的数据类型提供了一个替换名 它并没有创建一个新类型 因此 如果 两个函数参数表的区别只在于一个使用了 typedef 而另一个使用了与 typedef 相应的类型 则该参数表不被视为不同的 下列 calc()的两个函数声明被视为具有相同的参数表 第二个 声明导致编译时刻错误 因为虽然它声明了相同的参数表 但是它声明了与第一个不同的返 回类型 // typedef 并不引入一个新类型 typedef double DOLLAR; // 错误: 相同参数表 不同返回类型 extern DOLLAR calc( DOLLAR ); extern int calc( double ); 当一个参数类型是 const 或 volatile 时 在识别函数声明是否相同时 并不考虑 const 和 volatile 修饰符 例如 下列两个声明声明了同一个函数 // 声明同一函数 void f( int ); void f( const int ); 参数是 const 这只跟函数的定义有关系 它意味着 函数体内的表达式不能改变参数的 值 但是 对于按值传递的参数 这对函数的用户是完全透明的 用户不会看到函数对按值 传递的实参的改变 按值传递的实参以及参数的其他传递方式在 7.3 节中讨论 当实参 被按值传递时 将参数声明为 const 不会改变可以被传递给该函数的实参种类 任何 int 型的 实参都可以被用来调用函数 f(const int) 因为两个函数接受相同的实参集 所以刚才给出的 两个声明并没有声明一个重载函数 函数 f()可以被定义为 void f( int i ) { } 或 void f( const int i ) { } 然而 在同一个程序中同时提供这两个定义将产生错误 因为这些定义把一个函数定义 了两次 但是 如果把 const 或 volatile 应用在指针或引用参数指向的类型上 则在判断函数声明 372 第九章 重载函数 是否相同时 就要考虑 const 和 volatile 修饰符 // 声明了不同的函数 void f( int* ); void f( const int* ); // 也声明了不同的函数 void f( int& ); void f( const int& ); 9.1.3 何时不重载一个函数名 什么时候重载一个函数名没有好处 如果不同的函数名所提供的信息可使程序更易于理 解的话 则再用重载函数就没有什么好处了 下面是一个例子 下列函数集合在一个公共数 据抽象上进行操作 它们可能首先会被看作重载的对象 void setDate( Date&, int, int, int ); Date &convertDate( const string & ); void printDate( const Date& ); 这些函数在同一个数据类型 类 Date 上执行操作 但是并不共享同样的操作 在这种 情况下 与函数名相关的词汇复杂性来自于程序员的习惯 他用这一组操作集和公共数据类 型来命名函数 C++的类机制使得这种习惯变得不再必要 相反 这些函数应该成为类 Date 的成员 因为每个成员函数执行不同的操作 所以成员函数的名字应该表示它的操作 例如 #include class Date { public: set( int, int, int ); Date &convert( const string & ); void print(); // ... }; 下面是另外一个例子 下列 Screen 类的五个成员函数在 Screen 的光标上执行各种移动操 作 或许我们首先认为最好把这些函数以名字 move()重载 Screen& moveHome(); Screen& moveAbs( int, int ); Screen& moveRel( int, int, char *direction ); Screen& moveX( int ); Screen& moveY( int ); 最后两个实例并不能被重载 因为它们的参数表完全相同 为了提供一个惟一的标识 我们把两个函数如下压缩成一个 // moveX() 和 moveY() 组合后的函数 Screen& move( int, char xy ); 现在 每个函数都有了一个惟一的参数表 这样就能够用名字 move()重载该函数集合 373 第九章 重载函数 但是 根据我们的准则 重载函数是个坏主意 不同的函数名所提供的信息会被丢失 这使 程序更难于理解 尽管光标移动是所有这些函数共享的通用操作 但是 这些函数之间移动 的特性是惟一的 例如 moveHome()代表了光标移动的一个特殊实例 对程序的读者来说下 面两个调用哪一个更易于理解 对 Screen 类的用户来说下面两个调用哪个更容易记忆 // 哪一个更易于理解? myScreen.home(); // 我们认为是这个 myScreen.move(); 有时候 没有必要重载 可能也不需要不同的函数定义 在某些情况下 缺省实参可以 把多个函数声明压缩为一个函数中 例如 两个光标函数 moveAbs(int,int); moveAbs(int,int,char*); 可以通过第三个 char*型参数的有无来区分 如果这两个函数的实现十分类似 并且在向 函数传递参数时 如果能够找到一个 char*型缺省实参可以表示实参不存在时的意义 则这两 个函数就可以被合并 现在 正好有个这样的缺省实参——值为 0 的指针 move( int, int, char* = 0 ); 程序员最好抱这样的观点 并不是每个语言特性都是你要攀登的下一座山峰 使用语言 的特性应该遵从应用的逻辑 而不是简单地因为它的存在就必须要使用它 程序员不应该勉 强使用重载函数 只有在必要的地方使用它们 才会让人感觉自然 9.1.4 重载与域 重载函数集合中的全部函数都应在同一个域中声明 例如 一个声明为局部的函数将隐 藏而不是重载一个全局域中声明的函数 例如 #include void print( const string & ); void print( double ); // overloads print() void fooBar( int ival ) { // 独立的域 隐藏 print()的两个实例 extern void print( int ); // 错误: print( const string & )在这个域中被隐藏 print( "Value : " ); print( ival ); // ok: print( int ) 可见 } 我们也可以在一个类中声明一组重载函数 因为每个类都维持着自己的一个域 所以两 个不同类的成员函数不能相互重载 类成员函数将在第 13 章描述 而类成员函数的重载解析 将在第 15 章描述 我们也可以在一个名字空间内声明一组重载函数 每个名字空间也都维持着自己的一个 域 作为不同名字空间成员的函数不能相互重载 例如 #include namespace IBM { 374 第九章 重载函数 extern void print( const string < ); extern void print( double ); // 重载 print() } namespace Disney { // 独立的域: // 没有重载 IBM 的 print() extern void print( int ); } using声明和 using 指示符可以使一个名字空间的成员在另一个中可见 这些机制对于重 载函数的声明有一些影响 关于 using 声明和 using 指示符在 8.6 节介绍 using声明怎样影响重载函数呢 using 声明为一个名字空间的成员在该声明出现的域中 提供了一个别名 下面程序中的 using 声明会怎么样呢 namespace libs_R_us { int max( int, int ); int max( double, double ); extern void print( int ); extern void print( double ); } // using 声明 using libs_R_us::max; using libs_R_us::print( double ); // 错误 void func() { max( 87, 65 ); // 调用 libs_R_us::max( int, int ) max( 35.5, 76.6 ); // 调用 libs_R_us::max( double, double ) } 第一个 using 声明向全局域中引入了两个 libs_R_us::max()函数 于是 我们便可以在 func() 中调用这两个 max()函数 函数调用时的实参类型将决定哪个函数会被调用 第二个 using 声 明是个错误 用户不能在 using 声明中为一个函数指定参数表 对于 libs_R_us::pring()惟一有 效的 using 声明是 using libs_R_us::pring; using声明总是为重载函数集合的所有函数声明别名 为什么这个限制是有必要的呢 这 个限制可以确保名字空间 libs_R_us 的接口不会被破坏 很清楚 对如下的函数调用 print( 88 ); 名字空间的作者希望调用函数 libs_R_us::pring(int) 由于某种原因 库的作者给出了几 个不同的函数 若允许用户有选择地把一组重载函数中的一个函数 而不是全部函数加入到 一个域中 那么这将导致令人吃惊的程序行为 如果 using 声明向一个域中引入了一个函数 而该域中已经存在一个同名的函数 又会 怎样呢 记住 using 声明只是一个声明 由 using 声明引入的函数就好像在该声明出现的地 方被声明一样 因此 由 using 声明引入的函数重载了在该声明所出现的域中同名函数的其 他声明 例如 375 第九章 重载函数 #include namespace libs_R_us { extern void print( int ); extern void print( double ); } extern void print( const string & ); // libs_R_us::print( int ) 和 libs_R_us::print( double ) // 重载 print( const string & ) using libs_R_us::print; void fooBar( int ival ) { print( "Value: " ); // 调用全局 print( const string & ) print( ival ); // 调用 libs_R_us::print( int ) } using声明向全局域中加入了两个声明 一个是 print(int) 一个是 print(double) 这些声 明为名字空间 libs_R_us 中的函数提供了别名 这些声明被加入到 print()的重载函数集合中 它已经包含了全局函数 print(const string&) 当 fooBar()调用函数时 所有的 print()函数都将 被考虑 如果 using 声明向一个域中引入了一个函数 而该域中已经有同名函数且具有相同的参 数表 则该 using 声明就是错误的 如果在全局域中已经存在一个名为 print(int)的函数 则 using 声明不能为名字空间 libs_R_us 中的函数声明别名 print(int) 例如 namespace libs_R_us { void print( int ); void print( double ); } void print( int ); using libs_R_us::print; // 错误: print(int) 的重复声明 void fooBar( int ival ) { print( ival ); // 哪一个 print? ::print 还是 libs_R_us::print? } 我们已经知道了 using 声明是怎样影响重载函数的 现在让我们来了解一下 using 指示符 又是怎样影响重载函数的 using 指示符使名字空间成员就像在名字空间之外被声明的一样 通过去掉名字空间的边界 using 指示符把所有声明加入到当前名字空间被定义的域中 如果 在当前域中声明的函数与某个名字空间成员函数名字相同 则该名字空间成员函数被加入到 重载函数集合中 例如 #include namespace libs_R_us { extern void print( int ); extern void print( double ); } 376 第九章 重载函数 extern void print( const string & ); // using 指示符: // print(int), print(double) 和 print(const string &) // 是重载函数集的一部分 using namespace libs_R_us; void fooBar( int ival ) { print( "Value: " ); // 调用 global print(const string &) print( ival ); // 调用 libs_R_us::print(int) } 如果使用多个 using 指示符 情况也是这样 具有相同的名字 但是来自不同名字空间 的成员函数都将被加到同一重载函数集合中 例如 namespace IBM { int print(int); } namespace Disney { double print(double); } // using 指示符: // 从不同的名字空间形成函数的重载集合 using namespace IBM; using namespace Disney; long double print(long double); int main() { print(1); // 调用 IBM::print(int) print(3.1); // 调用 Disney::print(double) return 0; } 在全局域中的函数 print()的重载集合含有函数 print(double) print(int)以及 print(long double) 这些函数是 main()中调用该函数时需要被考虑的重载函数集 尽管这些函数最初是 在不同的名字空间域中被声明的 因此 同一重载函数集合中的函数都是在同一个域中被声明的 即使这些声明可能是用 使名字空间成员好像在其他域中声明的一样可见的 using 声明或 using 指示符 引入的 9.1.5 extern "c" 和重载函数 如 7.7 节所示 我们可以用链接指示符 extern "C" 来表示 C++程序中的某一个函数是 用程序设计语言 C 编写的 链接指示符 extern "C"对重载函数声明的影响又会怎样呢 重 载函数集合中的某些函数可以是 C++函数 而另外一些是 C 函数吗 链接指示符只能指定重载函数集中的一个函数 例如 包含下列两个声明的程序是非法 的 // 错误: 在一个重载函数集中有两个 extern "C" 函数 extern "C" void print( const char* ); extern "C" void print( int ); 377 第九章 重载函数 下面 calc()的重载说明了在一个重载函数集合上的典型的链接指示符的用法 class SmallInt { /* ... */ }; class BigNum { /* ... */ }; // 这个 C 函数可以在 C 和 C++程序中调用 // C++函数可以处理 C++类参数 extern "C" double calc( double ); extern SmallInt calc( const SmallInt& ); extern BigNum calc( const BigNum& ); C语言的 calc()函数可以被 C 程序调用 也可以被 C++程序调用 其他函数是 C++函数 它们含有类参数 只能在 C++程序中被调用 声明的顺序并不重要 链接指示符并不影响函数调用时对于函数的选择 只用参数类型来选择将被调用的函数 被选中的函数是与实参类型精确匹配的那个 例如 SmallInt si = 8; int main() { calc( 34 ); // 调用 C 写的 calc( double ) calc( si ); // 调用 C++ 写的 calc( const SmallInt & ) // ... return 0; } 9.1.6 指向重载函数的指针 我们可以声明一个指向重载函数集合里的某一个函数的指针 怎样做呢 例如 extern void ff( vector ); extern void ff( unsigned int ); // pf1 指向哪个函数? void ( *pf1 )( unsigned int ) = &ff; 因为 ff()是一个重载函数 所以只看初始化表达式&ff 编译器并不知道该选择哪个函数 为选择初始化该指针的函数 编译器要查找重载函数集合里与指针指向的函数类型只有相同 的返回类型和参数表的函数 在上个例子中 选择的是 ff(unsigned int) 如果没有函数与指针类型匹配 又该怎么办 如果是这样 将导致编译错误 例如 extern void ff( vector ); extern void ff( unsigned int ); // 错误: 无匹配: 无效参数表 void ( *pf2 )( int ) = &ff; // 错误: 无匹配: 无效返同类型 double ( *pf3 )( vector ) = &ff; 赋值的工作方式类似 如果一个重载函数的地址被赋值给一个函数指针 则该函数指针 的类型被用来选择赋值符号右边的函数 如果编译器没有找到与指针类型匹配的函数 则赋 值就是错误的 也就是说 在两个函数指针类型之间不能进行类型转换 matrix calc( const matrix & ); 378 第九章 重载函数 int calc( int, int ); int ( *pc1 )( int, int ) = 0; int ( *pc2 )( int, double ) = 0; // ... // ok: 匹配 int calc( int, int ); pc1 = &calc; // 错误: 无匹配: 无效的第二个参数类型 pc2 = &calc; 9.1.7 类型安全链接 重载允许同一个函数名以不同参数表出现多次 这是程序源代码层次上的词法便利 但 是 大多数编译系统的底层组件要求每个函数名必须惟一 这是因为大多数链接编辑器都是 按照函数名来解析外部引用的 如果链接编辑器看到两个以上的名为 print 的实例 它就不能 通过分析类型来区分不同的实体 在编译到这一点时 类型信息通常已经不存在了 链接 编辑器会标记 print 被定义多次 并退出 为处理这个问题 每个函数名及其相关参数表都被作为一个惟一的内部名编码 encoded 编译系统的底层组件只能看到编码后的名字 名字转换的细节并不重要 在不 同的编译器实现中 它们可能不同 一般的做法是把参数的个数和类型都进行编码 然后再 将其附在函数名后面 正如在 8.2 节关于全局函数的介绍中我们所看到的 这种特殊的编码可确保同名函数的 两个声明 它们有不同的参数表 处于不同的文件中 不会被链接编辑器当作同一个函数的 声明 因为这种编码帮助链接阶段区分程序中的重载函数 所以我们把它称作类型安全链接 type-safe linkage 这种特殊编码不适用于用链接指示符 extern "C"声明的函数 这就是为什么在重载函数 集合中只有一个函数可以被声明为 extern "C"的原因 具有不同的参数表的两个 extern "C" 的函数会被链接编辑器视为同一函数 练习 9.1 为什么我们要声明重载函数 练习 9.2 应该怎样声明下面 error()函数的重载函数集合以处理下列调用 int index; int upperBound; char selectVal; // ... error( "Array out of bounds: ", index, upperBound ); error( "Division by zero" ); error( "Invalid selection", selectVal ); 379 第九章 重载函数 练习 9.3 说出下列声明集合中第二个声明所造成的影响 (a) int calc( int, int ); int calc( const int, const int ); (b) int get(); double get(); (c) int *reset( int * ); double *reset( double * ); (d) extern "C" int compute( int *, int ); extern "C" double compute( double *, double ); 练习 9.4 下列哪些初始化是错误的 为什么 (a) void reset( int * ); void (*pf)( void * ) = reset; (b) int calc( int, int ); int (*pf1)( int, int ) = calc; (c) extern "C" int compute( int *, int ); int (*pf3)( int*, int ) = compute; (d) void (*pf4)( const matrix & ) = 0; 9.2 重载解析的三个步骤 函数重载解析 function overload resolution 是把函数调用与重载函数集合中的一个函数 相关联的过程 在存在多个同名函数的情况下 根据函数调用中指定的实参选择其中一个函 数 考虑下面的例子 T t1, t2; void f( int, int ); void f( float, float ); int main() { f( t1,t2 ); return 0; } 这里 根据给出的类型 T 函数重载解析过程将决定 f(t1,t2)调用的是 f(int, int)还是 f(float, float) 还要决定是因为用实参 t1 和 t2 不能调用任何一个函数 还是由于调用中指定 的实参与两个函数都精确匹配引起了二义性 ambiguous 使调用出错了 函数重载解析过程是 C++程序设计语言中最复杂的部分之一 C++初学者在开始时可能 会被它的全部细节吓倒 因此 本节只大概地浏览一下重载函数解析的过程 使你对发生的 380 第九章 重载函数 事情有个感性认识 希望进一步了解的读者将在下两节中看到有关函数重载解析的更详细地 描述 函数重载解析的过程有三个步骤 我们将用下面的例子解释这三步 void f(); void f( int ); void f( double, double = 3.4 ); void f( char*, char* ); int main() { f( 5.6 ); return 0; } 函数重载解析的步骤如下 1 确定函数调用考虑的重载函数的集合 确定函数调用中实参表的属性 2 从重载函数集合中选择函数 该函数可以在 给出实参个数和类型 的情况下用调用 中指定的实参进行调用 3 选择与调用最匹配的函数 下面我们将按顺序查看每一步 函数重载解析的第一步是确定对该调用所考虑的重载函数集合 该集合中的函数被称为 候选函数 candidate function 候选函数是与被调用函数同名的函数 并且在调用点上 它 的声明可见 在这个例子中 有四个候选函数 f() f(int) f(double, double)以及 f(char*, char*) 函数重载解析的第一步还要确定函数调用中的参数表的属性 即实参的数目和类型 在 本例中 实参表由一个 double 型的实参构成 函数重载解析的第二步是从第一步找到的候选函数中选择一个或多个函数 它们能够用 该调用中指定的实参来调用 因此 选出来的函数被称为可行函数 viable function 可行 函数的参数个数与调用的实参表中的参数数目相同 或者可行函数的参数个数多一些 但是 每个多出来的参数都要有相关的缺省实参 对于每个可行函数 调用中的实参与该函数的对 应的参数类型之间必须存在转换 conversion 在这个例子中 有两个可行函数 它们能够用调用中指定的实参表进行调用 f(int)是一个可行函数 因为它只有一个参数而且存在从实参类型 double 到参数类 型 int 之间的转换 f(double, double)也是一个可行函数 因为它的第二个参数给出了缺省值 而第一个 参数类型是 double 与实参类型精确匹配 如果函数重载解析过程的第二步没有找到可以用给定的实参表调用的可行函数 则该调 用就是错误的 没有函数与调用匹配 则说是无匹配情况 no match situation 函数重载解析的第三步选择与调用最匹配的函数 该函数被称为最佳可行函数 best viable function 通常也称为最佳匹配函数 best match function 为了选择这个函数 从实参类型到相应可行函数参数所用的转换都被划分等级 ranked 最佳可行函数是被适 用于如下规则的函数 1 应用在实参上的转换不比调用其他可行函数所需的转换差 381 第九章 重载函数 2 在某些实参上的转换要比其他可行函数对该参数的转换好 类型转换及其等级划分将在 9.3 节详细讨论 这里我们只简要地查看一下本例子中转换 的等级 当考虑可行函数 f(int)时 应用的转换是个标准转换 它将 double 型的实参转换成 int 型 当考虑可行函数 f(double)时 实参的类型 double 与相应的参数精确匹配 因为精确 匹配比标准转换好 不做转换比任何转换都好 所以该调用的最佳可行函数是 f(double, double) 如果函数重载解析的第三步没有找到最佳可行函数 则该函数调用是有二义的 即没有 找到一个比其他可行函数都好的函数 有关函数重载解析步骤的详细情况可在 9.4 节中找到 当一个重载的类成员函数被调用 时 或一个重载的操作符函数被调用时函数重载解析也是适用的 15.10 节将讨论类成员函 数重载解析的规则 而 15.11 节将讨论重载操作符的函数重载解析规则 函数重载解析过程 还必须考虑函数模板生成的函数 10.8 节将讨论函数模板如何影响函数重载解析过程 练习 9.5 函数重载解析过程的最后一步 第三步 发生的是什么 9.3 参数类型转换 在函数重载解析的第二步中 编译器确定 可以应用在函数调用的实参上的 将其转换 成每个可行函数中相应参数类型 的转换 并将其划分等级 这种等级有二种可能 1 精确匹配 exact match 实参与函数参数的类型精确匹配 例如 给出重载函数集 中的下列三个 printo 函数 则后面三个 print()调用都导致精确匹配 void print( unsigned int ); void print( const char* ); void print( char ); unsigned int a; print( 'a' ); // 匹配 print( char ); print( "a" ); // 匹配 print( const char* ); print( a ); // 匹配 print( unsigned int ); 2 与一个类型转换 type conversion 匹配 实参不直接与参数类型匹配 但是它能转 换成这样的类型 void ff( char ); ff( 0 ); // 从 int 到 char 转换实参 3 无匹配 no match 实参不能与声明的函数的参数匹配 因为在实参与相应的函数 参数之间无法进行类型转换 下列两个 print()调用导致无匹配 // print() 声明如下 int *ip; class SmallInt { /* ... */ }; SmallInt si; 382 第九章 重载函数 print( ip ); // 错误: 无匹配 print( si ); // 错误: 无匹配 精确匹配的实参并不一定与参数的类型完全一致 有一些最小转换可以被应用到实参上 在精确匹配的等级类别中可能存在的转换如下 从左值到右值的转换 从数组到指针的转换 从函数到指针的转换 限定修饰转换 我们会在后面更详细地介绍这些转换 与一个类型转换匹配 的等级类别是三个等级中最复杂的一个 几种类型转换都必须 考虑到 可能的转换被分成三组 提升 promotion 标准转换 standard conversion 和用 户定义的转换 user-defined conversions 提升和标准转换在本节后面介绍 用户定义的转 换将在详细讨论类 class 之后介绍 用户定义的转换由转换函数 conversion function 来 执行 它是类的一个成员函数 允许一个类定义自己的 标准 转换 在第 15 章我们将看到 类的转换函数以及涉及用户定义的转换的函数重载解析过程 为一个函数调用选择最佳可行函数时 编译器会选择在实参的类型转换方面 最好 的 一个函数 函数转换被划分等级如下 精确匹配比提升好 提升比标准转换好 标准转换比 用户定义的转换好 我们将在 9.4 节进一步了解类型转换的等级 但是现在 我们描述的是 各种可能的类型转换 本节中的某些例子会给出简单的情况 即怎样用这种等级划分来选择 最佳可行函数 9.3.1 精确匹配的细节 精确匹配最简单的例子是实参与函数参数类型精确匹配 例如 已知下面 max()重载函 数集中的两个函数 则后面两个 max()调用中的实参与重载集合的特定函数的参数精确匹配 int max( int, int ); double max( double, double ); int i1; void calc( double d1 ) { max( 56, i1 ); // 精确匹配 max( int, int ); max( d1, 66.9 ); // 精确匹配 max( double, double ); } 枚举类型定义了一个惟一的类型 它只与枚举类型中的枚举值以及被声明为该枚举类型 的对象精确匹配 例如 enum Tokens { INLINE = 128; VIRTUAL = 129; }; Tokens curTok = INLINE; enum Stat { Fail, Pass }; extern void ff( Tokens ); extern void ff( Stat ); 383 第九章 重载函数 extern void ff( int ); int main() { ff( Pass ); // 精确匹配 ff( Stat ) ff( 0 ); // 精确匹配 ff( int ) ff( curTok ); // 精确匹配 ff( Tokens ) // ... } 正如前面所提到的 即使一个实参必须应用一些最小的类型转换才能将其转换为相应函 数参数的类型 它仍然是精确匹配的 这些转换的第一个就是从左值到右值的转换 左值代表了一个可被程序寻址的对象 可 以从该对象读取一个值 除非该对象被声明为 const 否则它的值也可以被修改 相对来说 右值只是一个表达式 它表示了一个值 或一个引用了临时对象的表达式 用户不能寻址该 对象 也不能改变它的值 下面是一个简单的例子 int calc( int ); int main() { int lval, res; lval = 5; // 左值: lval; 右值: 5 res = calc( lval ); // 左值: res; // 右值: 存放 calc() 的返回值的临时对象 return 0; } 在第一个赋值表达式中 lval 是个左值 文字常量 5 是个右值 在第二个赋值表达式中 res 是个左值 函数 calc()调用返回值的临时对象是个右值 在某些情况下 当预计出现一个值的时候 我们也可以用一个左值表达式来实现 例如 int obj1; int obj2; int main() { // ... int local = obj1 + obj2; return 0; } obj1和 obj2 是左值表达式 但是 main()中的加法只需要存贮在 obj1 和 obj2 中的值 在 执行加法前 从 obj1 和 obj2 中把这些值抽取出来 从一个左值表达式所表示的对象中抽取 值的动作就是一个 从左值到右值的转换 当一个函数期望一个按值传递的实参 而该实参又是一个左值的时候 就会执行从左值 到右值的转换 例如 #include string color( "purple" ); void print( string ); int main() { print( color ); // 精确匹配: 从左值到右值的转换 384 第九章 重载函数 return 0; } 因为 print()调用中的实参是按值传递的 所以发生了从左值到右值的转换 它从 color 中抽取出一个值 将其传递给 print(string) 即使发生了从左值到右值的转换 实参 color 也 还是 print(string)的精确匹配 不是所有函数调用都要求实参进行从左值到右值的转换 一个引用表示一个左值 所以 当一个函数有一个引用参数时 被调用的函数接受一个左值 因此 不会有从左值到右值的 转换被应用到相应的引用参数的实参上 例如 已知函数 #include void print( list & ); 下列调用中的 li 是一个左值 代表被传递给函数 print()的 list对象 list li(20); int main() { // ... print( li ); // 精确匹配: 没有从左值到右值的转换 return 0; } li与引用参数的绑定是个精确匹配 精确匹配允许的第二种转换是从数组到指针的转换 如在 7.3 节中所提到的 函数参数 没有数组类型 取而代之的是参数被转换成指向数组首元素的指针 类似地 类型为 NT 数 组 这里 N 是数组元素的个数 T 是数组元素的类型 的实参总是被转换成 T 型的指针 实 参类型的转换是从数组到指针的转换 即使发生了转换 实参仍然被看作是 T 型指针参数的 精确匹配 例如 int ai[3]; void putValues(int *); int main() { // ... putValues(ai); // 精确匹配: 从数组到指针的转换 return 0; } 在函数 putValues()被调用之前 发生了从数组到指针的转换 将实参 ai 从三个 int 的数 组转换成 int 型的指针 即使 putValues()有一个指针参数 且在实参上发生了从数组到指针 的转换 但是该实参也仍是 putValues()调用的精确匹配 精确匹配允许的下一种转换是从函数到指针的转换 该转换在 7.9 节中已简要介绍过了 和数组类型参数一样 函数类型的参数自动被转换成指向函数的指针 函数类型的实参也自 动被转换成函数指针类型 这种实参类型的转换被称为从函数到指针的转换 即使发生了这 种转换 该实参仍被看作是函数指针类型参数的精确匹配 例如 int lexicoCompare( const string &, const string & ); typedef int (*PFI)( const string &, const string & ); void sort( string *, string *, PFI ); 385 第九章 重载函数 string as[10]; int main() { // ... sort( as, as + sizeof(as) / sizeof(as[0] - 1), lexicoCompare // 精确匹配: 从函数到指针的转换 ); return 0; } 在函数 sort()被调用之前 发生了从函数到指针的转换 它将实参 lexicoCompare 从函数 类型转换成函数指针类型 即使该函数期望接收的是一个指针而实参是一个函数名 即使发 生了从函数到指针的转换 该实参也仍然是 sort()的第三个参数的精确匹配 精确匹配的最后一种转换是限定修饰转换 这种转换只影响指针 它将限定修饰符 const 或 volatile 或两者 加到指针指向的类型上 例如 int a[5] = { 4454, 7864, 92, 421, 938 }; int *pi = a; bool is_equal( const int * , const int * ); int func( int *parm ) { // pi 和 parm 的精确匹配: 限定修饰转换 if ( is_equal( pi, parm ) ) // ... return 0; } 在函数 is_equal()被调用之前 实参 pi 和 parm 被从 int 型指针转换成指向 const int 型的 指针 该转换把 const 限定修饰符加到指针指向的类型上 所以是限定修饰转换 即使函数 期望两个 const int 的指针 而两个实参是指向 int 型的指针 这两个实参也仍然是 is_equal() 的参数的精确匹配 限定修饰转换只应用在指针指向的类型上 当参数是 const 或 volatile 类型 而实参不是 时 没有类型转换发生 extern void takeCI( const int ); int main() { int ii = ...; takeCI(ii); // 无转换发生 return 0; } 在 takedCI()调用中 即使参数是 const int 型 也不会有限定修饰转换被应用在 int 型的实 参 li 上 该实参是函数参数类型的精确匹配 如果实参是指针 且有 const 或 volatile 限定符应用在指针上 也是这样 extern void init( int *const ); 386 第九章 重载函数 extern int *pi; int main() { // ... init(pi); // 没有限制转换 return 0; } 由于 init()参数上的 const 限定修饰符只应用在指针本身上 而并没有应用在指针指向的 类型上 因此 编译器在考虑应用在实参上的转换时不会考虑 const 限定修饰符 因为没有 限定修饰转换被应用在实参 pi 上 所以该实参与函数参数类型精确匹配 精确匹配类别中的前三种转换 从左值到右值 从数组到指针以及从函数到指针的转换 通常被称为左值转换 lvalue transformation 正如在 9.4 节中即将看到的那样 虽然左值转 换和限定修饰转换都属于精确匹配类别 但是只需要左值转换的精确匹配比需要限定修饰转 换的要好 我们将在下节中更详细地讨论这些 精确匹配可以用一个显式强制转换强行执行 例如 已知重载函数集合 extern void ff(int); extern void ff(void *); 如下调用 ff( 0xffbc ); // 调用 ff(int) 与 ff(int)精确匹配 因为 0xffbc 是十六进制形式的 int 型文字常量 程序员可以如下提供 一个显式转换来强制调用 ff(void*) ff( reinterpret_cast(0xffbc) ); // 调用 ff(void*) 显式强制转换应用在实参上时 实参的类型就变成强制转换的结果 使用显式强制转换 的类型转换可以帮助指导函数重载解析 例如 如果因为实参与两个以上可行函数匹配 使 得函数重载解析的结果是二义的 则可以用显式强制转换来打破二义性 使函数调用被解析 为一个特殊的可行函数 9.3.2 提升的细节 提升实际上就是下列转换之一 char unsigned char 或 short 型的实参被提升为 int 型 如果机器上 int 型的字长比 short整型的长 则 unsigned short 型的实参被提升到 int 型 否则 它被提升到 unsigned int 型 float 型的实参被提升到 double 类型 枚举类型的实参被提升到下列第一个能够表示其所有枚举常量的类型 int unsigned int long 或 unsigned long 布尔型的实参被提升为 int 型 当实参的类型是上面描述的源类型之一 而函数参数的类型是相应被提升的类型时 则 应用该提升 例如 extern void manip( int ); 387 第九章 重载函数 int main() { manip( 'a' ); // 类型 char 被提升为 int return 0; } 字符文字的类型是 char 它的提升类型是 int 因为提升的类型与函数 manip()的参数类 型匹配 所以我们说函数调用要求提升它的实参 假设有下列例子 extern void print( unsigned int ); extern void print( int ); extern void print( char ); unsigned char uc; print( uc ); // print( int ): uc 只需要提升 在 unsigned char 类型只占一个字节 而 int 型占四个字节的机器上 由于类型 int 可以表 示 unsigned char 型的全部值 所以提升就是将一个 unsigned char 的实参变成 int 型 在上面 给定的重载函数声明 以及刚刚描述的结构中 与 unsigned char 型实参最匹配的函数是 print(int) 要匹配其他两个函数则要求应用标准转换 下面的例子说明了枚举型实参的提升 enum Stat { Fail, Pass }; extern void ff( int ); extern void ff( char ); int main() { // ok: 枚举常量 Pass 被提升到 int ff( Pass ); // ff( int ) ff( 0 ); // ff( int ) return 0; } 枚举类型的提升有时候会使人惊奇 编译器经常根据枚举常量的值来选择枚举类型的表 示 例如 假设有前面描述的结构 char 有一个字节 int 有四个字节 以及下面的枚举类型 enum e1 { a1, b1, c1 }; 因为只有三个枚举常量——a1 b1 和 c1——它们的值分别为 0 1 2 该枚举类型的所 有值都可以用 char 型表示 所以编译器常常会选择 char 型作为 e1 的表示 但是 假设我们 有另外一个枚举类型 e2 它有不同的枚举常量值 enum e2 { a2, b2, c2=0x80000000 }; 因为有一个枚举常量的值是 0x80000000 所以该编译器被迫为 e2 选择一个能够表示值 0x80000000 的表示 这个表示就是 unsigned int 因此 即使 e1 和 e2 都是枚举类型 它们的表示也并不相同 这使 e1 和 e2 被提升为不 同的类型 例如 #include string format( int ); string format( unsigned int ); 388 第九章 重载函数 int main() { format(e1); // 调用 format( int ) format(e2); // 调用 format( unsigned int ) return 0; } 在第一个 format()的调用中 因为实参的类型是 char 型表示的类型 e1 所以实参被提升 为 int 型 为该调用选择的函数是 format(int) 在 format()的第二个调用中 因为实参的类型 是 unsigned int 型表示的 e2 型 所以实参被提升为类型 unsigned int 这使得函数 format(unsigned int)被选择给第二个调用 因此 你应该知道 两个枚举类型在重载函数解析 期间的行为可能完全不同 解析过程根据枚举常量的值来决定它们被提升的类型 9.3.3 标准转换的细节 有五种转换属于标准转换 1 整值类型转换 从任何整值类型或枚举类型向其他整值类型的转换 不包括前面提升 部分中列出的转换 2 浮点转换 从任何浮点类型到其他浮点类型的转换 不包括前面提升部分中列出的转 换 3 浮点—整值转换 从任何浮点类型到任何整值类型或从任何整值类型到任何浮点类型 的转换 4 指针转换 整数值 0 到指针类型的转换和任何类型的指针到类型 void*的转换 5 bool 转换 从任何整值类型 浮点类型 枚举类型或指针类型到 bool 型的转换 下面是一些例子 extern void print( void* ); extern void print( double ); int main() { int i; print( i ); // 匹配 print( double ); // i 被一个标准转换从 int 转换到 double print( &i ); // 匹配 print( void* ); // &i 被标准转换从 int* 转换到 void* return 0; } 类别 1 2 和 3 中的转换是有潜在危险的转换 这是因为转换的目标类型不能表示源类型 的全部值 例如 类型 float 不能表示出 int 类型的所有值的精度 这也是这些类别中的转换 是标准转换而不是提升转换的原因 int i; void calc( float ); int main() { calc( i ); // 浮点——整值标准转换 // 潜在危险, 取决于 i 值 return 0; 389 第九章 重载函数 当用户调用函数 calc()时 浮点—整值标准转换把实参从 int 型转换成 float 型 根据存储 在 i 中值的情况 可能无法保证把 i 的值存储在类型 float 的参数内并且不损失精度 所有的标准转换都被视为是等价的 例如 从 char 到 unsigned char 的转换并不比从 char 到 double 的转换优先级高 类型之间的接近程度不被考虑 即 如果有两个可行函数要求对 实参进行标准转换以便匹配各自参数的类型 则该调用就是二义的 将被标记为编译错误 例如 下面给出的一对重载函数 extern void manip( long ); extern void manip( float ); 下列调用是二义的 int main() { manip( 3.14 ); // 错误: 二义性 // manip( float ) 也不会好到那里 return 0; } 文字常量 3.14 是 double 型的 通过标准转换两个函数都能匹配 因为可能存在有两种标 准转换 所以该调用被标记为二义的 没有一个标准转换比其他的标准转换更为优先 程序 员可以用显式强制转换来解决二义性的问题 比如 manip( static_cast( 3.14 ) ); // manip( long ) 或通过用 float 常量后缀 manip( 3.14F ); // manip( float ) 下面是一些其他函数调用的例子 因为它们都与重载函数集中的多个函数匹配 所以它 们都是二义的 并都被标记为错误 extern void farith( unsigned int ); extern void farith( float ); int main() { // 每个调用都是二义的 farith( 'a' ); // 实参类型为 char farith( 0 ); // 实参类型为 int farith( 2uL ); // 实参类型为 unsigned long farith( 3.14159 ); // 实参类型为 double farith( true ); // 实参类型为 bool return 0; } 有时标准指针转换看起来有些违反直觉 尤其是 0 可以被转换成任何指针类型 这样 创建的指针值被称为空指针值 null pointer value 同时 值 0 也可以是任何整型常量表达 式 例如 void set(int*); int main() { // 从 0 到 int* 的指针转换应用到两个实参上 set( 0L ); set( 0x00 ); return 0; 390 第九章 重载函数 return 0; } 常量表达式 0L long int 型的 0 以及常量表达式 0x00 十六进制的 0 都属于整型类型 因此能够被转换成 int*型的空指针值 但是 因为枚举类型不是整型 仍为 0 的枚举型值不能被转换成指针类型 例如 enum EN { zr = 0 }; set( zr ); // 错误: zr 不能被转换到 int* 对 set()的调用是错的 因为在枚举值 zr 和 int*型的参数之间不存在可能的转换 即使该 枚举值为 0 还有一些事情要注意 常量表达式 0 属于类型 int 把这常量表达式转换成指针类型的标 准转换是必需的 如果重载函数集中有一个函数 它的参数是 int 型 则对于实参 0 该函数 会被优先考虑 例如 void print( int ); void print( void * ); void set( const char* ); void set( char* ); int main() { print( 0 ); // 调用 print( int ) set( 0 ); // 二义 return 0; } 实参对 print(int)的调用是精确匹配 但是 为了调用 print(void*) 需要一个标准转换将 0 转换成指针类型 因为精确匹配比标准转换要好 所以该调用选择了函数 print(int) 对 set() 的调用是二义的 因为通过应用标准转换 0 与两个 set()函数的参数都匹配 且两个函数对 该调用一样好 所以它是二义的 最后一种指针转换允许将任何指针类型的实参转换成 void*型的参数 因为 void*是通用 的数据类型指针 所以它可以存放任何数据类型的指针值 下面是一些例子 #include extern void reset( void * ); int func( int *pi, string *ps ) { // ... reset( pi ); // 指针转换: int* 到 void* // ... reset( ps ); // 指针转换: string* 到 void* return 0; } 只有指向数据类型的指针才可以用指针标准转换将其转换成类型 void* 函数指针不能 用标准转换转换成类型 void* 例如 typedef int (*PFV)(); extern PFV testCases[10]; // 函数指针数组 extern void reset( void * ); 391 第九章 重载函数 int main() { // ... reset( testCases[0] ); // 错误: 在 int(*)() 之间不存在标准转换 return 0; } 9.3.4 引用 函数调用的实参或函数参数都可以是引用 那么 引用又是怎样影响类型转换规则的 呢 首先 我们来看一下如果实参是一个引用时会发生什么情况 实参的类型永远不会是引 用类型 当实参是一个引用时 该实参是一个左值 它的类型是引用所指的对象的类型 考 虑下列例子 int i; int & ri = i; void print( int ); int main() { print( i ); // int 型的左值实参 print( ri ); // 同样 return 0; } 两个函数调用的实参都是 int 型 而在第二个调用中引用被用作实参 对实参类型没有 任何影响 当一个实参是类型 T 的引用时 所考虑的标准转换和提升与该实参是 T 型对象时的一样 例如 int i; int& ri = i; void calc( double ); int main() { calc( i ); // 浮点—整值标准转换 calc( ri ); // 同样 return 0; } 那么 引用参数是怎样影响应用在实参上的转换的呢 实参与引用参数的匹配结果有下 面的两种可能 1 实参是引用参数的合适的初始值 在这种情况下 我们说该实参是参数的精确匹配 例如 void swap( int &, int & ); int manip( int i1, int i2 ) { // ... swap( i1, i2 ); // ok: 调用 swap( int &, int & ) // ... return 0; 392 第九章 重载函数 } 2 实参不能初始化引用参数 在这种情况下 没有匹配情况发生 实参不能被用来调用 该函数 例如 int obj; void frd( double & ); int main() { frd( obj ); // 错误: 参数必须是 const double & return 0; } 对 frd()的调用是错误的 实参类型是 int 必须被转换成 double 以匹配引用参数的类型 该转换的结果是个临时值 因为这种引用不是 const 型的 所以临时值不能被用来初始化该 引用 下面是在引用参数与实参之间没有匹配的另外一个例子 class B; void takeB( B& ); B giveB(); int main() { takeB( give() ); // 错误: 参数必须是 const B& return 0; } 对 takeB()的调用是错误的 实参是函数调用的返回值 它是一个临时值 不能被用来初 始化非 const 型的引用 在这两种情况下 如果引用参数是 const 型的引用 则实参就是参数的精确匹配 针对 下面给出的代码 void print( int ); void print(int&); int iobj; int &ri = iobj; int main() { print( iobj ); // 错误: 二义 print( ri ); // 错误: 二义 print( 86 ); // ok: 调用 print( int ) return 0; } 第一个函数调用是错误的 因为对象 iobj 是与两个函数 print()都精确匹配的实参 所以 函数调用是二义的 对第二个函数调用也一样 引用 ri 指向一个对象 它是两个函数的精确 匹配 但是 第三个调用是正确的 函数 print(int&)不是该调用的可行函数 整型常量是个 右值 不是非 const 引用参数的有效的初始值 在调用 print(86)的可行函数集中只有一个函数 print(int) 因为它是惟一的可行函数 所以它是该调用选择的函数 简而言之 对于引用参数来说 如果实参是该引用的有效初始值 则该实参是精确匹配 如果该实参不是引用的有效初始值 则不匹配 393 第九章 重载函数 练习 9.6 指出精确匹配中允许的两个最小转换 练习 9.7 在下列函数调用中 实参上的每个转换的等级是什么 (a) void print( int *, int); int arr[6]; print( arr, 6 ); // 函数调用 (b) void manip( int, int ); manip( 'a', 'z' ); // 函数调用 (c) int calc( int, int); double dobj; double = calc( 55.4, dobj ); // 函数调用 (d) void set( const int * ); int *pi; set( pi ); // 函数调用 练习 9.8 下列哪个函数调用是因为实参与函数参数之间不存在类型转换而发生错误 (a) enum Stat { Fail, Pass }; void test( Stat ); test( 0 ); // 函数调用 (b) void reset( void * ); reset( 0 ); // 函数调用 (c) void set( void * ); int *pi; set( pi ); // 函数调用 (d) #include list oper(); void print( list & ); print( oper() ); // 函数调用 (e) void print( const int ); int iobj; print( iobj ); // 函数调用 9.4 函数重载解析细节 如 9.2 节所述 函数重载解析过程有三个步骤 这些步骤可以总结如下 1 确定为该调用而考虑的候选函数 以及函数调用中的实参表属性 2 从候选函数中选出可行函数 也就是说 根据调用中指定的实参 实参数目和类型 394 第九章 重载函数 选择可以被调用的函数 3 对于 被用来将实参转换成可行函数参数类型的转换 划分等级 以便选出与调用最 匹配的函数 下面 我们来详细讨论这三个步骤 9.4.1 候选函数 候选函数与被调用的函数具有同样的名字 可以用下面两种方式找到候选函数 1 该函数的声明在调用点上可见 给出下列例子 void f(); void f( int ); void f( double, double = 3.4 ); void f( char*, char* ); int main() { f( 5.6 ); // 这个调用有四个候选函数 return 0; } 因为在全局域中声明的四个 f()在调用点上都可见 所以它们都是候选函数集的一部分 2 如果函数实参的类型是在一个名字空间中被声明的 则该名字空间中与被调用函数同 名的成员函数也将被加入到候选函数集中 例如 namespace NS { lass C { /* ... */ }; oid takeC( C& ); } // cobj 的类型是在名字空间 NS 中被声明的类 C NS::C cobj; int main() { // 在调用点没有 takeC()可见 takeC( cobj ); // ok: 调用 NS::takeC( C& ) // 因为实参类型是 NS::C // 所以考虑在名字空间 NS 中声明的函数 takeC() return 0; } 因此 候选函数是 在调用点上可见的函数 以及 在实参类型所在的名字空间中声明 的同名函数 的集合 当我们确定在调用点上可见的重载函数集合时 我们在前面看到的关于怎样生成重载函 数集的规则仍然适用 在嵌套的域中被声明的函数隐藏了而不是重载了外围域中的同名函数 这种情况下的候 选函数是在嵌套域中被声明的函数 即没有被该函数调用隐藏的函数 在下面的例子中 在 调用点上可见的候选函数是 format(double)和 format(char*) char* format( int ); void g() { char* format( double ); 395 第九章 重载函数 char* format( char* ); format(3); // 调用 format( double ) } 因为在全局域中声明的函数 format(int)被隐藏 所以它没有被包含在候选函数集中 在调用点上可见的 using 声明也可以引入候选函数 考虑下列例子 namespace libs_R_us { int max( int, int ); double max( double, double ); } char max( char, char ); void func() { // 名字空间的函数不可见 // 这三个调用分别调用全局函数 max( char, char ) max( 87, 65 ); max( 35.5, 76.6); max( 'J', 'L' ); } 名字空间 libs_R_us 中定义的函数 max()在调用点上不可见 惟一可见的是全局域中声明 的函数 max() 该函数是候选函数集中惟一的一个函数 它是 func()中三个调用所调用的函数 我们可以用 using 声明使名字空间 libs_R_us 中声明的函数 max()变为可见 那么 using 声明 应该放在哪儿呢 如果把 using 声明放在全局域中 char max( char, char ); using libs_R_us::max; // using 声明 那么 来自名字空间 libs_R_us 中的函数 max()就将被加到重载函数集中 该集合同时还 包含全局域中声明的函数 max() 现在 三个函数在 func()中都可见 并且都成为侯选函数集 中的一部分 随着三个函数在调用点上可见 func()中的调用被解析如下 void func() { max( 87, 65 ); // 调用 libs_R_us::max( int, int ) max( 35.5, 76.6 ); // 调用 libs_R_us::max( double, double ) max( 'J', 'L' ); // 调用 max( char, char ) } 但是 如果我们在函数 func()的局部域中如下引入了 using 声明又会怎么样呢 void func() { // using 声明 using libs_R_us::max; // 函数调用如上 } 候选函数集中会包含哪些 max() 请回忆一下 using 声明的嵌套 由于局部域中的 using 声明 全局函数 max(char, char)被隐藏 在调用点上可见的函数只是 396 第九章 重载函数 libs_R_us::max( int, int ) libs_R_us::max( double, double ) 这两个函数是候选函数集中的函数 func()中的调用被解析如下 void func() { // using 声明 // 全局 max( char, char ) 被隐藏 using libs_R_us::max; max( 87, 65 ); // 调用 libs_R_us::max( int, int ) max( 35.5, 76.6 ); // 调用 libs_R_us::max( double, double ) max( 'J', 'L' ); // 调用 libs_R_us::max( int, int ) } using指示符也会影响候选函数集的构成 假设我们决定用 using 指示符而不是 using 声 明使名字空间 lib_R_us 中的函数 max()在 func()中可见 例如 使用下面全局域中的 using 指示符 候选函数集就将包含全局函数 max(char,char)以及在名字空间 libs_R_us 中声明的函 数 max(int, int)和 max(double, double) namespace libs_R_us { int max( int, int ); double max( double, double ); } char max( char, char ); using namespace libs_R_us; // using 指示符 void func() { max( 87, 65 ); // 调用 libs_R_us::max( int, int ) max( 35.5, 76.6 ); // 调用 libs_R_us::max( double, double ) max( 'J', 'L' ); // 调用 ::max( char, char ) } 假若像下面这样 将 using 指示符放到 func()的局部域内 又会怎么样呢 void func() { // using 指示符 using namespace libs_R_us; // 函数调用如上 } 哪些 max()会成为候选函数 记住 using 指示符使名字空间成员可见就好像它们是在名 字空间之外 在定义名字空间的位置上被声明的一样 在我们的例子中 名字空间 libs_R_us 的成员在 func()的局部域内可见 就好像该成员已经在名字空间之外 全局域内被声明的 样 这暗示着在 func()内可见的重载函数集与前面包含下列三个函数的一样 max( char, char ) libs_R_us::max( int, int ) libs_R_us::max( double, double ) 397 第九章 重载函数 无论 using 指示符出现在全局域还是 func()的局部域内 都不会影响 func()中的调用的解 析过程 void func() { using namespace libs_R_us; max( 87, 65 ); // 调用 libs_R_us::max( int, int ) max( 35.5, 76.6 ); // 调用 libs_R_us::max( double, double ) max( 'J', 'L' ); // 调用 ::max( char, char ) } 所以 候选函数集是在调用点上可见的函数 包括 using 声明和 using 指示符引入的函数 以及在与实参类型相关的名字空间内被声明的成员函数 例如 namespace basicLib { int print( int ); double print( double ); } namespace matrixLib { class matrix { /* ... */ }; void print( const matrix & ); } void display() { using basicLib::print; matrixLib::matrix mObj; print( mObj ); // 调用 matrixLib::print( const matrix& ) print( 87 ); // 调用 basicLib::print( int ) } 哪些函数是调用 print(mObj)的候选函数 因为由函数 display()中的 using 声明引入的函 数 basicLib::print(int)和 basicLib::print(double)在调用点上可见 所以它们都是候选函数 因 为函数调用实参的类型是 matrixLib::matrix 所以在名字空间 matrixLib 中声明的函数 print() 也是个候选函数 调用 print(87)的候选函数是哪些呢 在调用点上只有函数 basicLib::print(int) 和 basicLib::print(double)可见 所以它们是候选函数 因为实参的类型是 int 所以编译器不 会在其他名字空间中寻找其他候选函数 9.4.2 可行函数 可行函数是候选函数集合中的函数 它的参数表或者与调用中的实参数目相同 或者有 更多的参数 在后一种情况下 额外的参数会被给出缺省实参 以便可以用实参表中指定的 实参调用该函数 可行函数是这样的函数 对于每个实参 都存在到函数参数表中相应的参 数类型之间的转换 可被考虑的转换是 9.3 节中介绍的转换 在下面的例子中 对调用 f(5.6)来说有两个可行函数 它们是 f(int)和 f(double) void f(); void f( int ); void f( double ); 398 第九章 重载函数 void f( char*, char* ); int main() { f( 5.6 ); // 两个可行函数: f( int ) 和 f( double ) return 0; } f(int)是可行函数 因为它只有一个参数 这与函数调用中实参的数目匹配 并且存在着 把实参从 double 型转换成 int 型的标准转换 f(double)也是个可行函数 这个可行函数只有 一个参数 类型为 double 是调用中实参的精确匹配 候选函数 f()和 f(char*, char*)被排除在 可行函数集合之外 是因为这些函数不能用一个实参调用 在下面的例子中 调用 format(3)的唯一可行函数是函数 format(double) 虽然候选函数 format(char*)也可以用一个实参调用 但是在 int 型的实参和 char*型的参数之间不存在转换 就因为不存在该类型转换 所以该函数被排除在可行函数集合之外 char* format( int ); void g() { // 全局函数 format( int ) 被隐藏 char* format( double ); char* format( char* ); format(3); // 只有一个可行函数: format( double ) } 在下面的例子中 三个候选函数都在 func()中 max()调用的可行函数集合中 这三个函数 都可以用两个实参来调用 因为实参类型是 int 它是 libs_R_us::max(int,int)的参数的精确匹 配 所以这两个实参可以通过 浮点—整值标准转换 转换成 libs_R_us::max(double,double) 的参数 以及通过 整值标准转换 转换成 max(char,char)的参数 namespace libs_R_us { int max( int, int ); double max( double, double ); } // using 声明 using libs_R_us::max; char max( char, char ); void func() { // 这三个 max() 都是可行函数 max( 87, 65 ); // 调用 libs_R_us::max( int, int ) } 注意 对于有多个参数的候选函数来说 只要函数调用中的一个实参不能被转换成候选 函数参数表中相应的参数 它就将马上被排除在可行函数集合之外 即使其他实参都存在转 换 在下面的例子中 函数 min(char*,int)被排除在可行函数之外 因为在第一个实参 int 类 型与相应函数参数 char*类型之间不存在转换 即使第二个实参是函数的第二个参数的精确匹 配 该函数也会被排除 extern double min( double, double ); extern int min( char*, int ); 399 第九章 重载函数 void func() { // 候选函数 min( double, double ) min( 87, 65 ); // 调用 min( double, double ) } 如果在去掉参数个数不同的候选函数 或去掉不存在合适的类型转换的候选函数 之后 没有可行函数存在 则该调用就会导致编译时刻错误 在这种情况下 我们就说没有找到匹 配 void print( unsigned int ); void print( char* ); void print( char ); int *ip; class SmallInt { /* ... */ }; SmallInt si; int main() { print( ip ); // 错误: 没有可行函数: 没有匹配 print( si ); // 错误: 没有可行函数: 没有匹配 return 0; } 9.4.3 最佳可行函数 最佳可行函数是具有与实参类型匹配最好的参数的可行函数 对于每个可行函数来说 每个实参的类型转换都被划分了等级 以决定每个实参与其相应参数的匹配程度 9.2 节描 述了得到支持的类型转换 最佳可行函数是满足下列条件的可行函数 1 用在实参上的转换不比调用其他可行函数所需的转换更差 2 在某些实参上的转换要比其他可行函数对该参数的转换更好 将实参转换成相应的函数参数时可能不只应用一种类型转换 例如 在下面的例子中 int arr[3]; void putValues(const int *); int main() { putValues(arr); // 在转换序列中有 2 个转换 // 数组到指针 限定修饰转换 return 0; } 将实参 arr 从三个 int 元素的数组转换成 const int 指针类型应用了一个转换序列 该转换 序列由下列转换构成 1 从数组到指针的转换 将实参从三个 int 元素的数组转换成 int 型的指针 2 限定修饰转换 把 int 型的指针转换成 const int 型的指针 因此说一个转换序列 conversion sequence 被用来把实参转换成可行函数参数的类型更 为合适 因为是一个转换序列而不是单个转换被应用到实参上将其转换成相应参数的类型 所以函数重载解析的第三步是将转换序列划分等级 400 第九章 重载函数 转换序列的等级是构成该序列最坏转换的等级 正如在 9.2 节中描述的 类型转换的等 级划分如下 精确匹配好于提升 提升好于标准转换 在前面的例子中 序列中的两个转换 都具有精确匹配的等级 一个转换序列潜在地由下列转换以下列顺序构成 左值转换—— 提升或者标准转换—— 限定修饰转换 左值转换 lvalue transformation 是指 9.2 节里讲的精确匹配类别中描述的前三个转换 从左值到右值的转换 从数组到指针的转换和从函数到指针的转换 转换序列的构成是这样 的 首先是 0 个或一个左值转换 接着是 0 个或一个提升 或者 0 个或一个标准转换 再后 面是 0 个或一个限定修饰转换 至多 每种转换会有一个被应用上 以将实参转换成相应的 参数 这种转换序列被称为标准 standard 转换序列 还有另外一种转换序列被称为用户定义 的 user-defined 转换序列 用户定义的转换序列包含类成员转换函数 类成员转换函数和 用户定义的转换序列将在 15 章讲述 下面的例子中实参上的转换序列是什么 namespace libs_R_us { int max( int, int ); double max( double, double ); } // using 声明 using libs_R_us::max; void func() { char c1, c2; max( c1, c2 ); // 调用 libs_R_us::max( int, int ) } 在 max()的调用中的实参是 char 型的 调用函数 libs_R_us::max(int, int)的实参上的转换 序列如下 1a 因为该实参按值传递 所以首先是从左值到右值转换 它从实参 c1 和 c2 中抽取值 2a 提升转换将实参从 char 转换到 int 调用函数 libs_R_us::max(double,double)的实参上的转换序列如下 1b 先应用一个从左值到右值的转换 它从实参 c1 和 c2 抽取值 2b 浮点——整值标准转换将实参从 char 转换成 double 第一个转换序列的等级是提升 序列中最差的转换 而第二个转换序列的等级是标准 转换 因为提升比标准转换好 所以函数 libs_R_us::max(int,int)被选为该调用的最佳可行函 数 或最佳匹配函数 如果通过对实参上的转换序列划分等级 仍然不能够判别出一个可行函数比其他函数更 匹配实参的类型 则该调用就是二义的 在下面的例子中 calc()的两个实例都要求下列转换 序列 401 第九章 重载函数 1 首先是一个从左值到右值的转换 它从实参 i 和 j 中抽取值 2 通过一个标准转换把实参转换成相应的参数 因为每个转换序列都和另一个一样好 所以该调用是二义的 int i, j; extern long calc( long, long ); extern double calc( double, double ); void jj() { // 错误: 二义, 没有最佳匹配 calc( i, j ); } 限定转换 把 const 或 volatile 修饰符加到指针指向的类型上的转换 具有精确匹配的等 级 但是 如果两个转换序列前面都相同 只是一个在序列尾部有一个额外的限定转换 则 另一个没有额外限定转换的序列比较好 例如 void reset( int * ); void reset( const int * ); int* pi; int main() { reset( pi ); //没有限定转换的比较好; 选择 reset( int * ) return 0; } 应用在调用第一个候选函数 reset(int*)的实参上的标准转换序列是个精确匹配 它只要求 一个从左值到右值的转换来抽取实参的值 对第二个候选函数 reset(const int*) 也应用了一 个从左值到右值的转换 接着是限定修饰转换把结果值从 int 指针转换成 const int 指针 这 两个序列都是精确匹配 但是上面的函数调用不是二义的 因为这两个转换序列的第一个转 换相同 但是第二个转换序列末尾有额外的限定修饰转换 所以第一个没有限定修饰转换的 序列被认为是较好的匹配 因此 可行函数 reset(int*)是最佳可行函数 下面是另一个例子 其中限定修饰转换影响了被选择的转换序列 int extract( void * ); int extract( const void * ); int* pi; int main() { extract( pi ); // 选择 extract( void * ) return 0; } 该调用有两个可行函数 extract(void*)和 extract(const void*) 在调用第一个可行函数 extract(void*)上应用的转换序列中 有一个从左值到右值的转换来抽取实参的值 接着是一 个标准指针转换 它将该值从一个 int 指针转换成一个 void 指针 应用在调用第二个函数 extract(const void*)上的转换序列也是如此 只不过还应用了一个额外的限定修饰转换 它将 结果从 void 指针转换成 const void 指针 因为这两个转换序列 除了第二个转换序列在尾部 402 第九章 重载函数 是一个额外的限定修饰转换外 其余都是相同的 所以第一个转换序列被选择为更好的转换 序列 函数 extract(void*)被选为该实参的最佳可行函数 const或 volatile 修饰符也能影响引用参数的初始化的等级 如同转换序列的情形一样 如果两个引用初始化是相同的 只不过其中一个增加了一个额外的 const 或 volatile 修饰符 那么对于函数重载解析来说 没有额外修饰符的引用初始化是比较好的引用初始化 例如 #include void manip( vector & ); void manip( const vector & ); vector f(); extern vector vec; int main() { manip( vec ); // 选择 manip( vector & ) is selected manip( f() ); // 选择 manip( const vector & ) is selected return 0; } 在第一个调用中 两个调用的引用初始化都是精确匹配 但是该调用不是二义的 因为 两个引用初始化都相同 除了第二个加上了 const 修饰符 所以没有额外限定修饰的初始化 被认为是较好的初始化 因此 可行函数 manip(vector&)是第一个调用的最佳可行函数 在第二个调用中 该调用只有一个可行函数 manip(const vector&) 因为实参是存 放函数 f()返回值的临时单元 所以该实参是一个右值 它不能被用来初始化 manip(vector&)的非 const 引用参数 因此 对于第二个调用的最佳可行函数 编译器只 考虑一个可行函数 manip(const vector&) 当然 函数调用可以有一个以上的实参 选择最佳可行函数时必须考虑转换全部实参所 需的转换序列的等级 我们来看一个例子 extern int ff( char*, int ); extern int ff( int, int ); int main() { ff( 0, 'a' ); // ff( int, int ) return 0; } 由于下述原因 有两个 int 型的参数的函数 ff()被选为最佳可行函数 1 它的第一个实参较好 0 是 int 型的参数的精确匹配 而对于第一个 ff(char*,int)函数 来说 它需要一个指针标准转换序列来匹配 char*型的参数 2 它们的第二个实参一样好 实参 a 的类型是 char 两个函数的第二个参数的匹配 都要求一个提升等级的转换序列 这里还有另外一个例子 int compute( const int&, short ); int compute( int&, double ); extern int iobj; int main() { 403 第九章 重载函数 compute( iobj, 'c' ); // compute( int&, double ) return 0; } 这两个函数 compute(const int&,short)和 compute(int&,double)都是可行函数 由于下述原 因第二个函数被选为最佳可行函数 1 它的第一个实参较好 第一个可行函数的引用初始化比较差 因为它加入了一个 const 限定修饰符 而第二个可行函数的初始化没有加入 const 限定修饰符 2 它们的第二个实参一样好 实参 c 的类型是 char 要匹配两个函数的第二个参数 都要求一个标准转换等级的转换序列 9.4.4 缺省实参 缺省实参可以使多个函数进入到可行函数集合中 可行函数是指可以用调用中指定的实 参进行调用的函数 可行函数可以有比函数调用实参表中的实参个数更多的参数 只要每个 多出来的参数都有相应的缺省实参即可 extern void ff( int ); extern void ff( long, int = 0 ); int main() { ff( 2L ); // 匹配 ff( long, 0 ); ff( 0, 0 ); // 匹配 ff( long, int ); ff( 0 ); // 匹配 ff( int ); ff( 3.14 ); // 错误: 二义 } 对于第一个和第三个调用 即使该实参表中只有一个实参 第二个函数 ff()仍然是两个 调用的可行函数 原因如下 1 函数的第二个参数有相应的缺省实参 2 函数的第一个参数是 long 型 与第一个调用的实参类型精确匹配 通过标准转换等 级的转换序列 与第三个调用的实参类型也匹配 最后一个调用是二义的 这是因为通过在第一个实参上应用标准转换 两个实例都可以 匹配 这里不能选择 ff(int)作为更好的函数 因为它只有一个实参 练习 9.9 解释在 main()中对 compute()的调用的函数重载解析过程中发生的事情 哪些函数是候选 函数 哪些函数是可行函数 应用在实参上使其与每个可行函数的参数匹配的类型转换序列 是什么 哪个函数 如果存在的话 是最佳可行函数 namespace primerLib { void compute( ); void compute( const void * ); } using primerLib::compute; void compute( int ); void compute( double, double = 3.4 ); 404 第九章 重载函数 void compute( char*, char* = 0 ); int main() { compute( 0 ); return 0; } 如果将 using 声明放在 main()中 但是在 compute()调用之前 会怎么样 回答与上面同 样的问题 10 函 数 模 板 本章将讲述什么是函数模板 function template 并讨论怎样定义和使用函数模 板 使用函数模板其实相当简单 许多 C++初学者在使用库中定义的函数模板时 甚至不知道他们在使用模板 只有高级 C++用户才会像本章中描述的那样定义和使 用函数模板 因此 本章的内容可被用作高级 C++主题的介绍性资料 我们从描述 什么是函数模板以及怎样定义函数模板开始 然后说明函数模板的简单用法 在这 之后 再将焦点转移到更高级的话题上 首先 我们将了解怎样以更高级的方式使 用函数模板 我们将详细了解模板实参的推演过程 看看当引用一个模板实例时 怎样指定显式模板参数 然后我们会了解编译器怎样初始化模板及其对程序组织结 构上的要求 并讨论怎样定义函数模板实例的特化版本 然后 本章将给出一些让 函数模板设计者感兴趣的话题 我们将解释函数模板怎样被重载 以及涉及函数模 板的重载解析过程如何工作 我们还会介绍函数模板定义中的名字解析 以及函数 模板如何才能被定义在名字空间中 最后 本章将以一个使用函数模板的例子作为 结束 10.1 函数模板定义 有时候 强类型语言对于实现相对简单的函数似乎是个障碍 例如 虽然下面的函数 min()的算法很简单 但是 强类型语言要求我们为所有希望比较的类型都实现一个实例 int min( int a, int b ) { return a < b ? a : b; } double min( double a, double b ) { return a < b ? a : b; } 有一种方法可替代这种 为每个 min()实例都显式定义一个函数 的方法 这种方法很有 吸引力 但是也很危险 那就是用预处理器的宏扩展设施 例如 #define min(a,b) ((a) < (b) ? (a) : (b)) 406 第十章 函数模板 虽然该定义对于简单的 min()调用都能正常工作 如 min( 10, 20 ); min( 10.0, 20.0 ); 但是 在复杂调用下 它的行为是不可预期的 这是因为它的机制并不像函数调用那样 工作 只是简单地提供参数的替换 结果是 它的两个参数值都被计算两次 一次是在 a 和 b 的测试中 另一次是在宏的返回值被计算期间 例如 #include #define min(a,b) ((a) < (b) ? (a) : (b)) const int size = 10; int ia[size]; int main() { int elem_cnt = 0; int *p = &ia[0]; // 计数数组元素的个数 while ( min(p++,&ia[size]) != &ia[size] ) ++elem_cnt; cout << "elem_cnt : " << elem_cnt << "\texpecting: " << size << endl; return 0; } 这个程序给出了计算整型数组 ia 的元素个数的一种明显绕弯的的方法 min()的宏扩展在 这种情况下会失败 因为应用在指针实参 p 上的后置递增操作随每次扩展而被应用了两次 执行该程序的结果是下面不正确的计算结果 elem_cnt : 5 expecting: 10 函数模板提供了一种机制 通过它我们可以保留函数定义和函数调用的语义 在一个程 序位置上封装了一段代码 确保在函数调用之前实参只被计算一次 而无需像宏方案那样 绕过 C++的强类型检查 函数模板提供一个种用来自动生成各种类型函数实例的算法 程序员对于函数接口 参 数和返回类型 中的全部或者部分类型进行参数化 parameterize 而函数体保持不变 如 用一个函数的实现在一组实例上保持不变 并且每个实例都处理一种惟一的数据类型 如函 数 min() 则该函数就是模板的最佳候选者 例如 下面是 min()的函数模板定义 template Type min( Type a, Type b ) { return a < b ? a : b; } int main() { // ok: int min( int, int ); min( 10, 20 ); // ok: double min( double, double ); min( 10.0, 20.0 ); 407 第十章 函数模板 return 0; } 如果用函数模板代替前面程序中的预处理器宏 min() 则程序的输出是正确的 elem_cnt : 10 expecting: 10 C++标准库为一些常用的算法 如这里定义的 min() 提供了函数模板 这些算法将在 第 12 章描述 为了介绍函数模板 我们对于 C++标准库中定义的一些算法给出了相应的简 化版本 关键字 template 总是放在模板的定义与声明的最前面 关键字后面是用逗号分隔的模板 参数表 template parameter list 它用尖括号 <> 一个小于号和一个大于号 括起来 该列表是模板参数表 不能为空 模板参数可以是一个模板类型参数 template type parameter 它代表了一种类型 也可以是一个模板非类型参数 template nontype parameter 它代表了一个常量表达式 模板类型参数由关键字 class 或 typename 后加一个标识符构成 在函数的模板参数表中 这两个关键字的意义相同 它们表示后面的参数名代表一个潜在的内置或用户定义的类型 模板参数名由程序员选择 在本例中 我们用 Type 来命名 min()的模板参数 但实际上可以 是任何名字 譬如 template Glorp min( Glorp a, Glorp b ) { return a < b ? a : b; } 当模板被实例化时 实际的内置或用户定义类型将替换模板的类型参数 类型 int double char* vector或 list*都是有效的模板实参类型 模板非类型参数由一个普通的参数声明构成 模板非类型参数表示该参数名代表了一个 潜在的值 而该值代表了模板定义中的一个常量 例如 size 是一个模板非类型参数 它代 表 arr 指向的数组的长度 template Type min( Type (&arr) [size] ); 当函数模板 min()被实例化时 size 的值会被一个编译时刻已知的常量值代替 函数定义或声明跟在模板参数表后 除了模板参数是类型指示符或常量值外 函数模板 的定义看起来与非模板函数的定义相同 我们来看一个例子 template Type min( const Type (&r_array)[size] ) { /* 找到数组中元素最小值的参数化函数 */ Type min_val = r_array[0]; for ( int i = 1; i < size; ++i ) if ( r_array[i] < min_val ) min_val = r_array[i]; return min_val; } 408 第十章 函数模板 在我们的例子中 Type 表示 min()的返回类型 参数 r_array 的类型 以及局部变量 min_val 的类型 size 表示 r_array 引用的数组的长度 在程序的运行过程中 Type 会被各种内置类 型和用户定义的类型所代替 而 size 会被各种常量值所取代 这些常量值是由实际使用的 min()决定的 记住 一个函数的两种用法是调用它和取它的地址 类型和值的替换过程 被称为模板实例化 template instantiation 我们将在下一节介绍模板实例化 我们的 min()函数模板的函数参数表看起来可能有些短 如 7.3 节所讨论的 一个数组参 数总是被作为指向数组首元素的指针来传递 数组实参的第一维在函数定义内是未知的 为 了缓解这个问题 我们在此处把 min()的参数声明为数组的引用 这解决了用户必须传递第二 个实参来指定数组长度的问题 但是缺点是用在不同长度的 int 数组时 会生成或实例化不 同的 min()实例 当一个名字被声明为模板参数之后 它就可以被使用了 一直到模板声明或定义结束为 止 模板类型参数被用作一个类型指示符 可以出现在模板定义的余下部分 它的使用方式 与内置或用户定义的类型完全一样 比如用来声明变量和强制类型转换 模扳非类型参数被 用作一个常量值 可以出现在模板定义的余下部分 它可以用在要求常量的地方 或许是在 数组声明中指定数组的大小或作为枚举常量的初始值 // size 指定数组参数的大小并初始化一个 const int 值 template Type min( const Type (&r_array)[size] ) { const int loc_size = size; Type loc_array[loc_size]; // ... } 如果在全局域中声明了与模板参数同名的对象 函数或类型 则该全局名将被隐藏 在 下面的例子中 tmp 的类型不是 double 是模板参数 Type typedef double Type; template Type min( Type a, Type b ) { // tmp 类型为模板参数 Type // 不是全局 typedef Type tmp = a < b ? a : b; return tmp; } 在函数模板定义中声明的对象或类型不能与模板参数同名 template Type min( Type a, Type b ) { // 错误: 重新声明模板参数 Type typedef double Type; Type tmp = a < b ? a : b; return tmp; } 模板类型参数名可以被用来指定函数模板的返回位 409 第十章 函数模板 // ok: T1 表示 min() 的返回类型 // T2 和 T3 表示参数类型 template T1 min( T2, T3 ); 模板参数名在同一模板参数表中只能被使用一次 例如 下面代码就有编译错误 // 错误: 模板参数名 Type 的非法重复使用 template Type min( Type, Type ); 但是 模板参数名可以在多个函数模板声明或定义之间被重复使用 // ok: 名字 Type 在不同模板之间重复使用 template Type min( Type, Type ); template Type max( Type, Type ); 一个模板的定义和多个声明所使用的模板参数名无需相同 例如 下列三个 min()的声明 都指向同一个函数模板 // 三个 min() 的声明都指向同一个函数模板 // 模板的前向声明 template T min( T, T ); template U min( U, U ); // 模板的真正定义 template Type min( Type a, Type b ) { /* ... */ } 模板参数在函数参数表中可以出现的次数没有限制 在下面的例子中 Type 用来表示两 个不同函数参数的类型 #include // ok: 在模板函数的参数表中多次使用 Type template Type sum( const vector &, Type ); 如果一个函数模板有一个以上的模板类型参数 则每个模板类型参数前面都必须有关键 字 class 或 typename // ok: 关键字 typename 和 class 可以混用 template T minus( T*, U ); // 错误: 必须是 template T sum( T*, U ); 在函数模板参数表中 关键字 typename 和 class 的意义相同 可以互换使用 它们两个 都可以被用来声明同一模板参数表中的不同模板类型参数 就如前面的函数模板 minus()所做 的 看起来 好像用 typename 而不是 class 来指派模板类型参数更为直观 毕竟 关键字 typename 名字能更清楚地表明后面的名字是个类型名 但是 关键字 typename 是最近才被 410 第十章 函数模板 加入到标准 C++中的 早期的程序员可能更习惯使用关键字 class 更不用说关键字 class 比 typename 要短一些 人们总是希望少打几个字嘛 通过将关键字 typename 加入到 C++中 使得我们可以对模板定义进行分析 这个话题有 些过于高深 我们只简要地解释为什么需要关键字 typename 对于想了解更多内容的读者 建议阅读 Stroustrup 的书 Design and Evolution of C++ 为了分析模板定义 编译器必须能够区分出是类型以及不是类型的表达式 对于编译器 来说 它并不总是能够区分出模板定义中的哪些表达式是类型 例如 如果编译器在模板定 义中遇到表达式 Parm::name 且 Parm 这个模板类型参数代表了一个类 那么 name 引用的是 Parm 的一个类型成员吗 template Parm minus( Parm* array, U value ) { Parm::name * p; // 这是一个指针声明还是乘法 乘法 } 编译器不知道 name 是否为一个类型 因为它只有在模板被实例化之后才能找到 Parm 表 示的类的定义 为了让编译器能够分析模板定义 用户必须指示编译器哪些表达式是类型表 达式 告诉编译器一个表达式是类型表达式的机制是在表达式前加上关键字 typename 例如 如果我们想让函数模板 minus()的表达式 Parm::name 是个类型名 因而使整个表达式是一个 指针声明 我们应如下修改 template Parm minus( Parm* array, U value ) { typename Parm::name * p; // ok: 指针声明 } 关键字 typename 也可以被用在模板参数表中 以指示一个模板参数是一个类型 如同非模板函数一样 函数模板也可以被声明为 inline 或 extern 应该把指示符放在模 板参数表后面 而不是在关键字 template 前面 // ok: 关键字跟在模板参数表之后 template inline Type min( Type, Type ); // 错误: inline 指示符放置的位置错误 inline template Type min( Array, int ); 练习 10.1 指出下列函数模板定义中哪些是错误的 并将其改正 (a) template void foo( T, U, V ); (b) template 411 第十章 函数模板 T foo( int *T ); (c) template T1 foo( T2, T3 ); (d) inline template T foo( T, unsigned int* ); (e) template void foo( myT, myT ); (f) template foo( T, T ); (g) typedef char Ctype; template Ctype foo( Ctype a, Ctype b ); 练习 10.2 下列模板重复声明中哪些是错的 为什么 (a) template Type bar( Type, Type ); template Type bar( Type, Type ); (b) template void bar( T1, T2 ); template void bar( C1, C2 ); 练习 10.3 将 7.3.3 小节中给出的函数 putValues()重写为模板函数 并且对函数模板进行参数化 使它有两个模板参数 一个是数组元素的类型 另一个是数组的长度 以及一个函数参数 该函数参数是一个数组的引用 同时给出函数模板定义 10.2 函数模板实例化 函数模板指定了怎样根据一组或更多实际类型或值构造出独立的函数 这个构造过程被 称为模板实例化 template instantiation 这个过程是隐式发生的 它可以被看作是函数模 板调用或取函数模板的地址的副作用 例如 在下面的程序中 min()被实例化两次 一次是 针对 5 个 int 的数组类型 另一次是针对 6 个 double 的数组类型 // 函数模板 min() 的定义 // 有一个类型参数 Type 和一个非类型参数 size 412 第十章 函数模板 template Type min( Type (&r_array)[size] ) { Type min_val = r_array[0]; for ( int i = 1; i < size; ++i ) if ( r_array[i] < min_val ) min_val = r_array[i]; return min_val; } // size 没有指定——ok // size = 初始化表中的值的个数 int ia[] = { 10, 7, 14, 3, 25 }; double da[6] = { 10.2, 7.1, 14.5, 3.2, 25.0, 16.8 }; #include int main() { // 为 5 个 int 的数组实例化 min() // Type => int, size => 5 int i = min( ia ); if ( i != 3 ) cout << "??oops: integer min() failed\n"; else cout << "!!ok: integer min() worked\n"; // 为 6 个 double 的数组实例化 min() // Type => double, size => 6 double d = min( da ); if ( d != 3.2 ) cout << "??oops: double min() failed\n"; else cout << "!!ok: double min() worked\n"; return 0; } 调用 int i = min( ia ); 被实例化为下面的 min()的整型实例 这里 Type 被 int size 被 5 取代 int min( int (&r_array)[5] ) { int min_val = r_array[0]; for ( int ix = 1; ix < 5; ++ix ) if ( r_array[ix] < min_val ) min_val = r_array[ix]; return min_val; } 类似地 调用 double d = min( da ); 也实例化了 min()的实例 这里 Type 被 double size 被 6 取代 413 第十章 函数模板 类型参数 Type 和非类型参数 size 都被用作函数参数 为了判断用作模板实参的实际类 型和值 编译器需要检查函数调用中提供的函数实参的类型 在我们的例子中 ia 的类型 即 5 个 int 的数组 和 da 的类型 即 6 个 double 的数组 被用来决定每个实例的模板实参 用 函数实参的类型来决定模板实参的类型和值的过程被称为模板实参推演 template argument deduction 我们将在下节更详细地介绍模板实参推演 我们也可以不依赖模板实参推演 过程 而是显式地指定模板实参 我们将在 10.4 节了解怎样实现这种方式 函数模板在它被调用或取其地址时被实例化 在下面的例子中 指针 pf 被函数模板实例 的地址初始化 编译器通过检查 pf 指向的函数的参数类型来决定模板实例的实参 template Type min( Type (&p_array)[size] ) { /* ... */ } // pf 指向 int min( int (&)[10] ) int (*pf)(int (&)[10]) = &min; pf的类型是指向函数的指针 该函数有一个类型为 int(&)[10]的参数 当 min()被实例化 时 该参数的类型决定了 Type 的模板实参的类型和 size 的模板实参的值 Type 的模板实参 为 int size 的模板实参为 10 被实例化的函数是 min(int(&)[10]) 指针 pf 指向这个模板实例 在取函数模板实例的地址时 必须能够通过上下文环境为一个模板实参决定一个惟一的 类型或值 如果不能决定出这个惟一的类型或值 就会产生编译时刻错误 例如 template Type min( Type (&r_array)[size] ) { /* ... */ } typedef int (&rai)[10]; typedef double (&rad)[20]; void func( int (*)(rai) ); void func( double (*)(rad) ); int main() { // 错误: 哪一个 min() 的实例? func( &min ); } 因为函数 func()被重载了 所以编译器不可能通过查看 func()的参数类型 来为模板参数 Type 决定惟一的类型 以及为 size 的模板实参决定一个惟一值 调用 func()无法实例化下面 的任何一个函数 min( int (*)(int(&)[10]) ) min( double (*)(double(&)[20]) ) 因为不可能为 func()指出一个惟一的实参的实例 所以在该上下文环境中取函数模板实 例的地址会引起编译时刻错误 如果我们用一个强制类型转换显式地指出实参的类型则可以消除编译时刻错误 int main() { // ok: 强制转换指定实参类型 func( static_cast< double(*)(rad) >(&min) ); } 414 第十章 函数模板 更好的方案是用显式模板实参 这一点我们将在 10.4 节中说明 10.3 模板实参推演 当函数模板被调用时 对函数实参类型的检查决定了模板实参的类型和值 这个过程被 称为模板实参推演 template argument deduction 函数模板 min()的函数参数是一个引用 它指向了一个 Type 类型的数组 template Type min( Type (&r_array)[size] ) { /* ... */ } 为了匹配函数参数 函数实参必须也是一个表示数组类型的左值 下面的调用是个错误 因为 pval 是 int*类型而不是 int 数组类型的左值 void f( int pval[9] ) { // 错误: Type (&)[] != int* int jval = min( pval ); } 在模板实参推演期间决定模板实参的类型时 编译器不考虑函数模板实例的返回类型 例如 对于如下的 min()调用 double da[8] = { 10.3, 7.2, 14.0, 3.8, 25.7, 6.4, 5.5, 16.8 }; int i1 = min( da ); min()的实例有一个参数 它是一个指向 8 个 double 的数组的指针 出该实例返回的值的 类型是 double 型 该返回值先被转换成 int 型 然后再用来初始化 i1 即使调用 min()的结果 被用来初始化一个 int 型的对象 也不会影响模板实参的推演过程 要想成功地进行模板实参推演 函数实参的类型不一定要严格匹配相应函数参数的类型 下列三种类型转换是允许的 左值转换 限定转换和到一个基类 该基类根据一个类模板实 例化而来 的转换 让我们依次来看一看 左值转换包括从左值到右值的转换 从数组到指针的转换或从函数到指针的转换 这些 转换在 9.3 节中介绍 为说明左值转换是怎样影响模板实参推演过程的 让我们考虑函数 min2() 它有一个名为 Type 的模板参数以及两个函数参数 min2()的第一个函数参数是一个 Type*型的指针 而 size 则不再像在 min()中定义的是个模板参数 size 变成一个函数参数 当 min2()被调用时 我们必须显式地为它指定一个函数实参值 template // 第一个参数是 Type* Type min2( Type* array, int size ) { Type min_val = array[0]; for ( int i = 1; i < size; ++i ) if ( array[i] < min_val ) min_val = array[i]; return min_val; } 我们可以用 4 个 int 的数组来作为第一个实参调用 min2() 如下 415 第十章 函数模板 int ai[4] = { 12, 8, 73, 45 }; int main() { int size = sizeof (ai) / sizeof (ai[0]); // ok: 从数组到指针的转换 min2( ai, size ); } 函数实参 ai 的类型是 4 个 int 的数组 虽然这与相应的函数参数类型 Type*并不严格匹 配 但是因为允许从数组到指针的转换 所以实参 ai 在模板实参 Type 被推演之前被转换成 int*型 Type 的模板实参接着被推演为 int 最终被实例化的函数模板是 min2(int*,int) 限定修饰转换把 const 或 volatile 限定修饰符加到指针上 限定修饰转换在 9.3 节中介绍 为说明限定修饰转换是怎样影响模板实参推演过程的 我们考虑函数 min3() 它的第一个函 数参数的类型是 const Type* template // 第一个参数是 const Type* Type min3( const Type* array, int size ) { // ... } 我们可以用 int*型的第一个参数调用 min3() 如下 int *pi = &ai; // ok: 到 const int* 的限定修饰转换 int i = min3( pi, 4 ); 函数实参 pi 的类型是 int 指针 虽然与相应的函数参数类型 const Type*并不完全匹配 但是因为允许限定修饰转换 所以函数实参在模板实参被推演之前 就先被转换为 const Type*型了 然后 Type 的模板实参被椎演为 int 被实例化的函数模板是 min3(const int*, int) 现在 再让我们来看看到一个基类 该基类根据一个类模板实例化而来 的转换 如果 函数参数的类型是一个类模板 且如果实参是一个类 它有一个从被指定为函数参数的类模 板实例化而来的基类 则模板实参的推演就可以进行 为说明这个转换 我们使用一个新的 被称为 min4()的函数模板 它有一个类型为 Array&的参数 这里的 Array 是在 2.5 节 中定义的类模板 第 16 章将给出类模板的完全讨论 template class Array { /* ... */ }; template Type min4( Array& array ) { Type min_val = array[0]; for ( int i = 1; i < array.size(); ++i ) if ( array[i] < min_val ) min_val = array[i]; return min_val; } 我们可以用类型为 ArrayRC的第一个实参调用 min4() ArrayRC 也是第 2 章定义的 416 第十章 函数模板 类模板 类继承将在 17 章和 18 章中讨论 如下 template class ArrayRC : public Array { /* ... */ }; int main() { ArrayRC ia_rc( ia, sizeof(ia)/sizeof(int) ); min4( ia_rc ); } 函数实参 ia_rc 的类型是 ArrayRC 它与相应的函数参数类型 Array&并不完 全匹配 因为类 ArrayRC有一个 Array的基类 而 Array是一个从被指定为函 数参数的类模板实例化而来的类 并且派生类类型的函数实参还可以被用来推演一个模板实 参 所以函数实参 ArrayRC在模板实参被推演之前首先被转换成 Array型 然后 Type 的模板实参再被推演为 int 被实例化的函数模板是 min4(Array&) 多个函数实参可以参加同一个模板实参的推演过程 如果模板参数在函数参数表中出现 多次 则每个推演出来的类型都必须与根据模板实参推演出来的第一个类型完全匹配 例如 template T min5( T, T ) { /* ... */ } unsigned int ui; int main() { // 错误: 不能实例化 min5( unsigned int, int ) // 必须是: min( unsigned int, unsigned int ) 或 // min( int, int ) min5( ui, 1024 ); } mins()的函数实参必须类型相同 要么都是 int 要么都是 unsigned int) 这是因为模板 参数 T 必须被绑定在一个类型上 从第一个函数实参推演出的 T 的模板实参是 int 而从第 二个函数实参推演出的是 unsigned int 因为对于两个函数实参 模板实参 T 的类型被推演成 不同类型 所以模板实参推演将失败 并且模板实例化也会出错误 一种解决办法是在调 用 min5()时显式指定模板实参 我们将在 10.4 节介绍怎样实现它 这些可能的类型转换的限制只适用于参加模板实参推演过程的函数实参 对于所有其他 实参 所有的类型转换都是允许的 下面的函数模板 sum()有两个参数 针对第一个参数的 实参 op1 参与模板实参推演过程 而针对第二个参数的实参 op2 则没有参与 template Type sum( Type op1, int op2 ) { /* ... */ } 因为第二个实参不参与模板实参推演过程 所以当函数模板 sum()的实例被调用时 可 以在第二个实参上应用任何类型转换 9.3 节描述了可以应用在函数实参上的类型转换 例如 int ai[] = { ... }; double dd; int main() { // sum( int, int ) 被实例化 sum( ai[0], dd ); } 417 第十章 函数模板 第一个函数实参 dd 的类型与相应的函数参数类型 int 不匹配 但是 对函数模板 sum() 实例的调用不是错的 这是因为第二个实参的类型是固定的 不依赖于模板参数 对于该调 用 函数 sum(int,int)被实例化 实参 dd 被通过浮点—有序标准转换转换成类型 int 所以模板实参推演的通用算法如下 1 依次检查每个函数实参 以确定在每个函数参数的类型中出现的模板参数 2 如果找到模板参数 则通过检查函数实参的类型 推演出相应的模板实参 3 函数参数类型和函数实参类型不必完全匹配 下列类型转换可以被应用在函数实参上 以便将其转换成相应的函数参数的类型 左值转换 限定修饰转换 从派生类到基类类型的转换 假定函数参数具有形式 T T&或 T* 则这里的参数表 args 至少含有一个模板参数 4 如果在多个函数参数中找到同一个模板参数 则从每个相应函数实参推演出的模板实 参必须相同 练习 10.4 指出在模板实参推演过程中涉及到的函数实参时两种可行的类型转换 练习 10.5 已知下列模板定义 template Type min3( const Type* array, int size ) { /* ... */ } template Type min5( Type p1,Type p2 ) { /* ... */ } 下列哪些调用是错误的 为什么 double dobj1, dobj2; float fobj1, fobj2; char cobj1, cobj2; int ai[5] = { 511, 16, 8, 63, 34 }; (a) min5( cobj2, 'c' ); (b) min5( dobj1, fobj1 ); (c) min3( ai, cobj1 ); 10.4 显式模板实参 在某些情况下编译器不可能推演出模板实参的类型 如在上节函数模板 min5()的例子中 所看到的 如果模板实参推演过程为同一模板实参推演出两个不同的类型 则编译器会给出 一个错误 指出模板实参推演失败 在这种情况下 我们需要改变模板实参推演机制 并使用显式指定 explicitly specify 模板实参 模板实参被显式指定在逗号分隔的列表中 用尖括号 <> 一个小于号和一个 418 第十章 函数模板 大于号 括起来 紧跟在函数模板实例的名字后面 例如 在我们前面使用的 min5()中 假 定希望 T 的模板实参是 unsigned int 则函数模板实例 min5()的调用可以重写如下 // min5( unsigned int, unsigned int ) 被实例化 min5< unsigned int >( ui, 1024 ); 在这种情况下 模板实参表显式地指定了模板实参的类型 因为模板实参 已知 所以函数调用不再是一个错误 注意 在调用函数 min5()时 第二个函数实参是 1024 它的类型是 int 因为第二个函 数参数的类型通过显式模板实参已被固定为 unsigned int 所以第二个函数实参通过有序标准 转换被转换成类型 unsigned int 在上节中我们看到 能在函数实参上进行的只是类型转换是有限的 从 int 到 unsigned int 的有序标准转换就不允许 但是当模板实参被显式指定时 就没有必要推演模板实参了 函 数参数的类型已经固定 当函数模板实参被显式指定时 把函数实参转换成相应函数参数的 类型可以应用任何隐式类型转换 除了允许在函数实参上的类型转换 显式模板参数还为解决其他的程序设计问题提供了 方案 考虑下面的问题 我们希望定义一个名为 sum()的函数模板 以便从该模板实例化的 函数可以返回某一种类型的值 该类型足够大 可以装下两种以任何顺序传递来的任何类型 的两个值的和 我们该怎样做呢 应该指定 sum()的返回类型吗 // 以 T 或 U 作为返回类型? template ??? sum( T, U ); 在我们的例子中 答案是两个参数都不用 因为使用它们中的任何一个都会导致在某点 上失败 char ch; unsigned int ui; // T 和 U 都不用作返回类型 sum( ch, ui ); // ok: U sum( T, U ); sum( ui, ch ); // ok: T sum( T, U ); 一种方案是通过引入第三个模板参数来指明函数模板的返回类型 // T1 不出现在函数模板参数表中 template T1 sum( T2, T3 ); 因为返问类型可能与函数实参类型不同 所以 T1 在函数参数表中不再被提起 这是一 个潜在的问题 因为 T1 的模板实参不能从函数实参中被推演出来 但是 如果在 sum()的一 个实例调用中给出一个显式模板实参 我们就能避免编译器错误地指出 T1 的模板实参不能 被推演出来 例如 typedef unsigned int ui_type; ui_type calc( char ch, ui_type ui ) { // ... // 错误: T1 不能被推演出来 ui_type loc1 = sum( ch, ui ); 419 第十章 函数模板 // ok: 模板实参被显式指定 // T1 和 T3 是 unsigned int, T2 是 char ui_type loc2 = sum< ui_type, char, ui_type >( ch, ui ); } 我们真正期望的是 为 T1 指定一个显式模板实参 而省略 T2 和 T3 的显式模板实参 T2 T3 的模板实参可以从该调用的函数实参中推演出来 在显式特化 explicit specification 中 我们只需列出不能被隐式推演的模板实参 如同 缺省实参一样 我们只能省略尾部的实参 例如 // ok: T3 是 unsigned int // T3 从 ui 的类型中推演出来 ui_type loc3 = sum< ui_type, char >( ch, ui ); // ok: T2 是 char, T3 是 unsigned int // T2 和 T3 从 pf 的类型中推演出来 ui_type (*pf)( char, ui_type ) = &sum< ui_type >; // 错误: 只能省略尾部的实参 ui_type loc4 = sum< ui_type, , ui_type >( ch, ui ); 在其他情形下 编译器不可能从使用函数模板实例的上下文中推演出模板实参 没有显 式模板实参 在这种上下文环境中就不可能使用函数模板实例 我们必须意识到需要支持这 些清形 这导致了标准 C++对显式模板实参提供了支持 在下面的例子中 sum()实例的地址 被取出 并作为重载函数 manipulate()调用的实参被传递 如在 10.2 节中所述 想只通过查 看 manipulate()的参数表就能选择出作为实参传递的 sum()实例 这是不可能的 sum()的两个 不同实例都可以被实例化 并满足该调用 但 manipulate()的调用是二义的 消除调用二义性 的一个方案是提供一个显式强制类型转换 而更好的方案是使用显式模板实参 显式模板实 参指明 sum()的哪个实例被使用 以及哪个 manipulate()被调用 例如 template T1 sum( T2 op1, T3 op2 ) { /* ... */ } void manipulate( int (*pf)( int,char ) ); void manipulate( double (*pf)( float,float ) ); int main( ) { // 错误: 哪一个 sum 的实例? // int sum( int, char ) 还是 // double sum( float, float ) ? manipulate( &sum ); // 取实例: double sum( float, float ) // 调用: void manipulate( double (*pf)( float, float ) ); manipulate( &sum< double, float, float > ); } 我们必须指出 显式模板实参应该只被用在完全需要它们来解决二义性 或在模板实参 不能被推演出来的上下文中使用模板实例时 首先 让编译器来决定模板实参的类型和值是 比较容易的 其次 如果我们通过修改程序中的声明来改变在函数模板实例调用中的函数实 参的类型 则编译器会自动用不同的模板实参实例化函数模板 而无需我们做任何事情 另 420 第十章 函数模板 一方面 如果我们指定了显式模板参数 则必须检查显式模板实参对于函数实参的新类型是 否仍然合适 所以建议在可能的时候省略显式模板实参 练习 10.6 指出两种必须使用显式模板实参的情形 练习 10.7 已知下面 sum()的模板定义 template T1 sum( T2, T3 ); 下列哪些调用是错误的 为什么 double dobj1, dobj2; float fobj1, fobj2; char cobj1, cobj2; (a) sum( dobj1, dobj2 ); (b) sum( fobj1, fobj2 ); (c) sum( cobj1, cobj2 ); (d) sum( fobj2, dobj2 ); 10.5 模板编译模式 函数模板的定义可用来作为一个无限个函数实例集合定义的规范描述 prescription 模板本身不能定义任何函数 例如 当编译器实现看到下面的模板定义时 template Type min( Type t1, Type t2 ) { return t1 < t2 ? t1 : t2; } 它就保存了 min()的内部表示形式 但是不会使任何其他事情发生 后来 当它看到 min() 被实际使用时 如 int i, j; double dobj = min( i, j ); 才根据模板定义为 min()实例化一个整型的定义 这带来了几个问题 如果希望编译器能够实例化函数模板 那么函数模板 min()的定义必 须在实例被调用之前就可见吗 例如 在上面的例子中 min()的整型实例被用在 dobj 的定 义中 那么在此之前函数模板 min()的定义必须先出现吗 我们把函数模板定义放在头文件中 就好像对内联函数定义的做法一样 在使用函数模板实例的地方包含它们 或者我们只 在头文件中给出函数模板声明 而把模板定义放在文本文件中 就好像对非内联函数的做法 一样 为了回答这些问题 我们必须解释 C++模板编译模式 template compilation model 它 421 第十章 函数模板 指定了对于定义和使用模板的程序的组织方式的要求 C++支持两种模板编译模式 包含模 式 Inclusion Model 和分离模式 Separation Model 本节余下部分将分别描述这两种模 式 并具体说明它们的用法 10.5.1 包含编译模式 在包含编译模式下 我们在每个模板被实例化的文件中包含函数模板的定义 并且往往 把定义放在头文件中 像对内联函数所做的那样 我们已经把这种模式选为本书使用的模式 例如 // model1.h // 包含模式: 模板定义放在头文件中 template Type min( Type t1, Type t2 ) { return t1 < t2 ? t1 : t2; } 在每个使用 min()实例的文件中都包含了该头文件 例如 // 在使用模板实例之前包含模板定义 #include "model1.h" int i, j; double dobj = min( i, j ); 该头文件可以被包含在许多程序文本文件中 这意味着编译器必须在每个调用该实例的 文件中实例化 min()的整型实例吗 不 该程序必须表现得好像 min()的整型实例只被实例化 一次 但是 真正的实例化动作发生在何时何地 要取决于具体的编译器实现 现在就我们 所关心的来说 我们只需要知道 min()的整型实例在程序中的某个地方被实例化 如我们在 本节结束时将看到的 用显式实例化声明可指定在何时何地进行模板实例化 这样的声明有 时候必须在产品开发的后期被使 以来改善应用程序的性能 在头文件中提供函数模板定义有几个缺点 函数模板体 body 描述了实现细节 对于 这些细节 用户可能想忽略 或者我们希望隐藏起来不让用户知道 实际上 如果函数模板 的定义非常大 那么在头文件中给出的细节层次有可能是不可接受的 而且 在多个文件之 间编译相同的函数模板定义增加了不必要的编译时间 分离编译模式允许我们分离函数模板 的声明和定义 下面让我们看一下怎样使用它 10.5.2 分离编译模式 在分离编译模式下 函数模板的声明被放在头文件中 在这种模式下 函数模板声明和 定义的组织方式与程序中的非内联函数的声明和定义组织方式相同 例如 // model2.h // 分离模式: 只提供模板声明 template Type min( Type t1, Type t2 ); // model2.C // the template definition export template 422 第十章 函数模板 Type min( Type t1, Type t2 ) { /* ...*/ } 使用函数模板 min()实例的程序只需在使用该实例之前包含这个头文件 // user.C #include "model2.h" int i, j; double d = min( i, j ); // ok: 用法, 需要一个实例 即使 min()的模板定义在 user.C 中不可见 仍然可以在这个文件中调用模板实例 min(int,int) 但是 为了实现它 模板 min()必须以一种特殊的方式被定义 你知道怎样做吗 如果仔细看一下定义了函数模板 min()的文件 model2.C 你就会注意到在模板定义中有一个 关键字 export 模板 min()被定义成一个可导出的 exported 模板 关键字 export 告诉编译器 在生成被其他文件使用的函数模板实例时可能需要这个模板定义 编译器必须保证 在生成 这些实例时 该模板定义是可见的 我们通过在模板定义中的关键字 template 之前加上关键字 export 来声明一个可导出的 函数模板 当函数模板被导出时 我们就可以在任意程序文本文件中使用模板的实例 而我 们所需要做的就是在使用之前声明该模板 如果省略了模板 min()定义中的关键字 export 则 编译器实现可能不能实例化函数模板 min()的整型实例 而我们将不能正确链接我们的程序 注意 有些编译器实现可能不要求用关键字 export 有些实现可能支持下列语言扩展 非导出的函数模板定义可能只出现在一个程序文本文件中 在其他程序文本文件中用到的实 例仍然被正确地实例化 但是 这种行为只是一个扩展 如果在模板被实例化之前只有函数 模板的声明在程序文本文件中可见 那么 标准 C++要求用户把函数模板定义标记为 export 关键字 export 不需要出现在头文件的模板声明中 在 model2.h 中的 min()的声明中没有 指定关键字 export 此关键字也可以出现在该声明中 但不是必需的 在程序中 一个函数模板只能被定义为 export 一次 不幸的是 因为编译器每次只处理 一个文件 所以它不能检测到一个函数模板在多个文本文件中被定义为 export 的情况 如果 发生了这样的事情 下列行为就有可能随之发生 1 可能产生一个链接错误 指出函数模板在多个文件中被定义 2 编译器可能不只一次地为同一个模板实参集合实例化该函数模板 由于函数模板实例 的重复定义 这会引起链接错误 3 编译器可能用其中一个 export 函数模板定义来实例化函数模板 而忽略其他定义 所以 在程序中提供多个 export 函数模板的定义不一定会产生错误 我们必须小心谨慎 地组织程序 以便把 export 函数模板定义只放在一个程序文本文件中 分离模式使我们能够很好地将函数模板的接口同其实现分开 进而组织好程序 以便把 函数模板的接口放到头文件中 而把实现放在文本文件中 但是 并不是所有的编译器都支 持分离模式 即使支持也未必总能支持得很好 支持分离模式需要更复杂的程序设计环境 所以它们不能在所有 C++编译器实现中提供 21 对于本书的目的而言 因为模板例子非常小 而且我们希望这些例子在许多 C++实现中 21 本书的姊妹篇 Inside the C++ Object Model 描述了一个 C++编译器 the Edison Design Group compiler 支持的模板实例化机制 该书的中文简体版已经出版 423 第十章 函数模板 都能够方便地被编译 所以我们只使用包含模式 10.5.3 显式实例化声明 当我们使用包含模式时 每个使用模板实例的程序文本文件都要包含函数模板的定义 我们已经看到 尽管程序不能确切地知道编译器在何时何地实例化函数模板 但是它必须表 现得好像对一个特定的模板实参集合只实例化一次模板 在实际中 有些编译器 尤其是老 的 C++编译器 对特殊的模板实参集合会多次实例化函数模板 在这种模式下 程序会选择 这些实例中的一个作为最终的实例 当程序被链接或在某个预链接阶段 而其他实例只是 被简单地忽略掉 无论函数模板被实例化一次还是多次 程序结果都不会受到影响 因为最终只有一个模 板实例被程序使用 但是 如果函数模板被多次实例化 则程序的编译时间性能可能会受到 很大影响 如果应用程序由许多文件构成 并且所有这些文件中的模板都被实例化 那么编 译应用程序所需要的时间会显著地增加 早期编译器的实例化问题使得模板用起来非常困难 为了解决这个问题 标准 C++提供 了显式实例化声明来帮助程序员控制模板实例化发生的时间 在显式实例化声明中 关键字 template 后面是函数模板实例的声明 其中显式地指定了 模板实参 在下面的例子中 提供了 sum(int*,int)的显式实例化声明 template Type sum( Type op1, int op2 ) { /* ... */ } // 显式实例化声明 template int* sum< int* >( int*, int ); 该显式实例化声明要求用模板实参 int*实例化模板 sum() 对于给定的函数模板实例 显 式实例化声明在一个程序中只能出现一次 在显式实例化声明所在的文件中 函数模板的定义必须被给出 如果该定义不可见 则 该显式实例化声明是错误的 #include template Type sum( Type op1, int op2 ); // declaration only // 声明一个 typedef 引用 vector< int > typedef vector< int > VI; // 错误: sum() 没有定义 template VI sum< VI >( VI , int ); 当一个显式实例化声明在程序文本文件中出现时 在其他使用该函数模板实例的文件中 又发生了什么事情 我们怎样告诉编译器 一个显式实例化声明已在另一个文件中出现 而 在程序其他文件使用该模板函数时不能再对它实例化 显式实例化声明是与另外一个编译选项联合使用的 该选项压制了程序中模板的隐式实 例化 选项的名称随着编译器不同而小同 例如 对 IBM 编译器 Visual Age for C++ for Windows 版本 3.5 压制模板隐式实例化的选项为/ft- 当我们用这个选项编译应用程序时 424 第十章 函数模板 编译器假定我们将会用显式实例化声明处理模板实例 所以它不会隐式地实例化应用程序用 到的模板 当然 如果我们不为一个函数实例提供显式实例化声明 则在编译程序时指定选项/ft- 就会产生一个链接错误 认为缺少函数模板实例化的定义 在这种情况下 模板不会被隐式 地实例化 练习 10.8 指出 C++支持的两种模板编译模式 并解释在这些模板编译模式下怎样组织含有函数模 板定义的程序 练习 10.9 给出下列 sum()的模板定义 template Type sum( Type op1, char op2 ); 怎样为 string 类型的模板实参声明一个显式实例化声明 10.6 模板显式特化 我们并不总是能够写出对所有可能被实例化的类型都是最合适的函数模板 在某些情况 下 我们可能想利用类型的某些特性 来编写一些比模板实例化的函数更高效的函数 在有 些时候 一般性的模板定义对于某种类型来说并不适用 例如 假设我们有函数模板 max() 的定义 // 通用的模板定义 template T max( T t1, T t2 ) { return (t1 > t2 ? t1 : t2); } 如果函数模板用 const char*型的模板实参实例化 并且我们还想让每个实参都被解释为 C 风格的字符串 而不是字符的指针 则通用模板定义给出正确的语义就不正确了 为了获 得正确的语义 我们必须为函数模板实例化提供特化的定义 在模板显式特化定义 explicit specialization definition 中 先是关键字 template 和一对 尖括号 <> 一个小于号和一个大于号 然后是函数模板特化的定义 该定义指出了模板 名 被用来特化模板的模板实参 以及函数参数表和函数体 在下面的例子中 为 max(const char*, const char*)定义了一个显式特化 #include // const char* 显式特化: // 覆盖了来自通用模板定义的实例 typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ) { 425 第十章 函数模板 return ( strcmp( s1, s2 ) > 0 ? s1 : s2 ); } 由于有了这个显式特化 当在程序中调用函数 max(const char*,const char*)时 模板不会 用类型 const char*来实例化 对所有用两个 const char*型实参进行调用的 max() 都会调用这 个特化的定义 而对于其他的调用 根据通用模板定义实例化一个实例 然后再调用它 这 些函数可能的调用如下 #include // 函数模板 max() 的定义以及对 const char* 的特化 int main() { // 调用实例: int max< int >( int, int ); int i = max( 10, 5 ); // 调用显式特化: const char* max< const char* >( const char*, const char* ); const char *p = max( "hello", "world" ); cout << "i: " << i << " p: " << p << endl; return 0; } 我们也可以声明一个函数模板的显式特化而不定义它 例如 函数 max(const char*,const char*)的显式特化可以被声明如下 // 函数模板显式特化的声明 template<> PCC max< PCC >( PCC, PCC ); 在声明或定义函数模板显式特化时 我们不能省略显式特化声明中的关键字 template 及 其后的尖括号 类似地 函数参数表也不能从特化声明中省略掉 // 错误: 无效的特化声明 // 缺少 template<> PCC max< PCC >( PCC, PCC ); // 缺少函数参数表 template<> PCC max< PCC >; 但是 如果模板实参可以从函数参数中推演出来 则模板实参的显式特化可以从显式特 化声明中省略 // ok: 模板实参 const char* 可以从参数类型中推演出来 template<> PCC max( PCC , PCC ); 在下面的例子中 函数模板 sum()被显式特化 template T1 sum( T2 op1, T3 op2 ); // 显式特化声明 // 错误: T1 的模板实参不能被推演出来 // 它必须显式指定 426 第十章 函数模板 template<> double sum( float, float ); // ok: T1 的实参被显式指定 // T2 和 T3 可以从 float 推演出来 template<> double sum( float, float ); // ok: 所有实参都显式指定 template<> int sum( char , char ); 省略显式特化声明中的 template<>并不总是错的 例如 // 通用模板定义 template T max( T t1, T t2 ) { /* ... */ } // ok: 普通函数定义 const char* max( const char*, const char* ); 但是 max()的声明并没有声明函数模板特化 它只是用与模板实例相匹配的返回值和参 数表声明了一个普通函数 声明一个与模板实例相匹配的普通函数并不是个错误 为什么我们要声明一个与模板实例相匹配的普通函数而不声明一个显式特化呢 如在 9.3 节所见到的 如果该实参参与模板实参的推演过程 则只能应用有限的一些类型转换把 函数模板实例的实参转换成相应的函数参数类型 函数模板被显式特化的情形也是这样 只 有 10.3 节描述的那些类型转换才可以被应用在函数模板显式特化的实参上 显式特化并没有 帮助我们越过类型转换的限制 如果想进行该类型转换集合之外的转换 那么就必须定义一 个普通函数而不是一个函数模板的特化 10.8 节将更详细地介绍 并给出怎样解析一个与普 通函数和函数模板实例都匹配的调用 即使函数模板显式特化所指定的函数模板只有声明而没有定义 我们仍然可以声明函数 模板显式特化 在前面的例子中 函数模板 sum()在被特化之前只是被声明了一下 尽管不 需要函数定义 但是模板声明也还是需要的 在名字 sum()被特化之前 编译器必须知道它 是个模板 在源文件中 使用函数模板显式特化之前 必须先进行声明 例如 #include #include // 通用模板定义 template T max( T t1, T t2 ) { /* ... */ } int main() { // const char* max( const char*, const char* ) 的实例 // 使用通用模板定义 const char *p = max( "hello", "world" ); cout << " p: " << p << endl; return 0; } 427 第十章 函数模板 // 无效程序: const char* 显式特化覆盖了通用模板函数 typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ) { /* ... */ } 因为上面的例子在声明显式特化之前使用了 max(const char*,const char*)的实例 所以 编译器只好假定该函数需要从通用模板定义中实例化 但是 一个程序不能对相同的模板实 参集的同一模板同时有一个显式特化和一个实例 当在程序文本文件中再遇到 max(const char*,const char*)的显式特化时 编译器会提示一个编译时刻错误 如果程序由一个以上的文件构成 则模板显式特化的声明必须在使用该特化的每个文件 中都可见 像下面这样的情况是不允许的 在有些文件中 函数模板被根据通用模板定义实 例化 而在其他文件中 对同一模板实参的集合却被特化 考虑下面的例子 // ---- max.h ---- // 通用模板定义 template Type max( Type t1, Type t2 ) { /* ... */ } // ---- File1.C ---- #include #include "max.h" void another(); int main() { // const char* max( const char*, const char* ) // 的实例 const char *p = max( "hello", "world" ); cout << " p: " << p << endl; another(); return 0; } // ---- File2.C ---- #include #include #include "max.h" // const char* 的模板显式特化 typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ) { /*... */ } void another() { // const char* max< const char* >( const char*, const char* ) // 的显式特化; const char *p = max( "hi", "again" ); cout << " p: " << p << endl; return 0; } 上面的程序由两个文件构成 在 File1.C 中 没有显式特化 max(const char*,const char*) 428 第十章 函数模板 的声明 函数模板被根据通用模板定义实例化 在 File2.C 中声明了显式特化 调用 max("hi", "again")就会调用该显式特化 因为同一程序在一个文件中实例化了函数模板实例 max(const char*,const char*) 而在另一个文件中又调用了这显式特化 所以这个程序是非法的 为了补 救这个问题 显式特化的声明必须在文件 File1.C 中 max(const char*,const char*)调用之前被 给出 为了防止出现这样的错误 并确保模板显式特化 max(const char*,const char*)的声明被包 含在每个用类型 const char*型实参调用函数模板 max()的文件中 显式特化的声明应该被放 在头文件 max.h 中 并在所有使用函数模板 max()的程序中包含这个文件 // ---- max.h ---- // 通用模板定义 template Type max( Type t1, Type t2 ) { /* ... */ } // const char* 模板显式特化的声明 typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ); // ---- File1.C ---- #include #include "max.h" void another(); int main() { // const char* max( const char*, const char* ) 的特化; const char *p = max( "hello", "world" ); //.... } 练习 10.10 请定义一个函数模板 count() 以记录数组中某个值出现的次数 写个程序调用它 并按 顺序向其传递 double int 和 char 型的数组 以及引入 count()函数的一个特化模板实例来处 理字符串 最后 再重新运行调用该函数模板实例的程序 10.7 重载函数模板 函数模板可以被重载 例如 下面给出函数模板 min()的三个有效的重载声明 // 类模板 Array 的定义 // (introduced in Section 2.4) template class Array{ /* ... */ }; // min() 的三个函数模扳声明 429 第十章 函数模板 template Type min( const Array&, int ); // #1 template Type min( const Type*, int ); // #2 template Type min( Type, Type ); // #3 下面的 main()定义说明了这三个 min()声明可以被怎样调用 #include int main() { Array iA(1024); // 类实例 int ia[1024]; // Type == int; min( const Array&, int ) int ival0 = min( iA, 1024 ); // Type == int; min( const int*, int ) int ival1 = min( ia, 1024 ); // Type == double; min( double, double ) double dval0 = min( sqrt( iA[0] ), sqrt( ia[0] ) ); return 0; } 当然 成功地声明一组重载函数模板并不能保证它们可以被成功地调用 在调用一个模 板实例时 重载的函数模扳可能会导致二义性 下面是将出现这种二义性的一个例子 我们 前面曾讲到 对于下列 min5()的模板定义 template int min5( T, T ) { /* ... */ } 即使用不同类型的实参调用 min5() 编译器也不能根据模板定义实例化函数 因为函数 实参推演过程为 T 推演出两个不同的类型 所以模板实参推演失败 调用是错误的 int i; unsigned int ui; // ok: 为 T 推演出: int min5( 1024, i ); // 模板实参推演失败 // 为 T 推演出两个不同的类型 min5( i, ui ); 为了解析第二个调用 我们可以重载 min5() 允许两个不同的实参类型 template int min5( T, U ); 下列函数调用则调用了这个新函数模板的实例 430 第十章 函数模板 // ok: int min5( int, unsigned int ) min5( i, ui ); 不幸的是 原先的调用现在已变成二义的了 // 错误: 二义性: 来自 min5( T, T ) 和 min5( T, U ) 的两个可能的实例 min5( 1024, i ); min5()的第二个声明允许两个不同类型的函数实参 但是 它没有要求它们一定是不同 的 在这种情况下 T 和 U 都可以是 int 型 对于两个实参类型相同的调用 这两个模板声 明都可以被实例化 要指明哪个函数模板比较好 并且消除调用的二义性的惟一方法是显式 指定模板实参 关于显式模板实参的讨论见 10.4 节 例如 // ok: 从 min5( T U ) 实例化 min5( 1024, i ); 但是 在这种情况下 我们其实可以取消重载函数模板 因为 min5(T,U)处理的调用集 是由 min5(T,T)处理的超集 所以应该只提供 min5(T,U)的声明 而 min5(T,T)应该被 删除 因此 正如我们在第 9 章开始时所说的 尽管重载是可能的 但是我们在设计重载函 数时 仍然必须小心确保重载是必需的 这些设计限制也同样适用于定义重载函数模板 在某些情况下 即使对于一个函数调用 两个不同的函数模板都可以实例化 但是该函 数调用仍然可能不是二义的 已知 sum()的下列两个模板定义 下面就是一种情况 虽然从 这两个函数模板的任一个都可以生成一个实例 但是第一个模板定义比较好 template Type sum( Type*, int ); template Type sum( Type, int ); int ia[1024]; // Type == int ; sum( int*, int ); or // Type == int*; sum( int*, int ); ?? int ival1 = sum( ia, 1024 ); 真让人吃惊 上面的调用居然没有二义性 该模板是用第一个模板定义实例化的 为该 实例选择的模板函数是最特化的 most specialized 因此 Type 的模板实参是 int 而不是 int* 一个模板要比另一个更特化 两个模板必须有相同的名字 相同的参数个数 对于不同 类型的相应函数参数 如上面的 T*和 T 一个参数必须能接受另一个模板中相应参数能够接 受的实参的超集 例如 对模板 sum(Type*,int) 第一个函数参数只能匹配指针类型的实 参 对于模板 sum(Type,int) 第一个函数参数可以匹配指针类型以及任意其他类型的实参 第二个模板接受第一个模极所能够接受的类型的超集 接受更有限的实参集合的模板被称为 是更特化的 在我们的例子中 模板 sum(Type*,int)是更特化的 它是为了例子中的函数 调用而被实例化的模板 431 第十章 函数模板 10.8 考虑模板函数实例的重载解析 如上节所述 函数模板可以被重载 函数模板可以与一个普通非模板函数同名 例如 // 函数模板 template Type sum( Type, int ) { /* ... */ } // 普通 非模板 函数 double sum( double, double ); 当程序调用 sum()时 该调用可以被解析为函数模板的实例 或者被解析为普通函数 到底调用哪个函数取决于这些函数中的哪一个与函数实参类型匹配得最好 在第 9 章中介绍 的函数重载解析过程被用来决定哪个函数与函数调用中的实参最匹配 例如 考虑下列代码 void calc( int ii, double dd ) { // 调用模板实例还是普通函数? sum( dd, ii ); } sum(dd,ii)调用由模板实例化的函数 还是调用普通非模板函数 为了回答这个问题 让我们逐步分析函数重载解析过程 函数重载解析的第一步是构造可以被调用的候选函数集 该集合由与被调用函数同名的函数构成 在调用点上 这些函数都有一个声明是可见的 当存在一个函数模板时 如果用该函数调用的实参可以实例化一个函数 则从该模板实 例化的实例也是一个候选函数 一个函数是否能被实例化 取决于模板实参推演过程是否能 进行 模板实参推演的过程在 10.3 节中说明 在前面的例子中 函数实参 dd 被用来推 演 Type 的模板实参 被推演出来的模板实参是 double 而模板实例 sum(double,int)被加 入到候选函数集中 因此 该调用有两个候选函数 模板实例 sum(double,int)和普通函数 sum(double,double) 一旦模板实例被加入到候选函数集中 函数重载解析过程就会像以前一样进行 函数重载解析的第二步是从候选函数集中选择可行函数集 可行函数是这样的候选函数 对它而言 存在着能够把每个函数实参转换成相应的函数参数类型的类型转换 9.3 节给 出了可以被应用在函数实参上的类型转换 对实例 sum(double,int)和非模板函数 sum (double,double)都存在类型转换 所以 这两个函数都是可行函数 函数重载解析的第三步是为应用在实参上的类型转换划分等级 以便选择最佳可行函数 针对我们的例子 等级划分如下 对于函数模板实例 sum(double,int) 1 因为第一个实参的实参和参数类型都是 double 该转换是精确匹配 2 因为第二个实参的实参和参数的类型都是 int 该转换也是精确匹配 对非模板函数 sum(double,double) 1 因为第一个实参的实参和参数类型都是 double 该转换也是精确匹配 2 因为第二个实参的实参类型是 int 参数类型是 double 应用的转换是浮点——有序 标准转换 当只考虑第一个实参时 两个函数一样好 但是 函数模板实例对于第二个参数更适合 432 第十章 函数模板 一些 所以 对该调用选择得到的最佳可行函数是实例 sum(double,int) 只有当模板实参推演成功时 函数模板实例才能进入候选函数集 所以即使模板实参推 演失败 它也不是一个错误 在这种情况下 没有函数实例被加入到候选函数集中 例如 假设函数模板 sum()被声明如下 // 函数模板 template int sum( T*, int ) { .... } 如果与前面相同的函数调用 模板实参推演过程将会失败 因为对于一个 double 型的函 数实参来说不可能有 T*型的相应参数 因为对该调用来说并没有实例可以从该函数模板产 生 所以也就不会有实例被加入到候选函数集中 那么在候选函数集中惟一的函数就是非模板 函数 sum(double,double) 则它就是此调用选择出来的函数 第二个实参将转换成 double 型 如果模板实参推演成功 但是被推演的模板实参被显式特化 又会怎么样呢 进入候选 函数集合的是显式特化 它将代替从通用模板定义实例化的函数 例如 // 函数模板定义 template Type sum( Type, int ) { /* ... */ } // Type == double 的显式特化 template<> double sum( double,int ); // 普通 (非模板) 函数 double sum( double, double ); void manip( int ii, double dd ) { // 调用模板显式特化 sum () sum( dd, ii ); } 对 manip()中的 sum()的调用 模板实参推演过程发现从通用模板定义生成的实例 sum (double,int)应该进入候选函数集合 但是 sum(double,int)有一个显式特化 所以进入 候选函数集的是显式特化 实际上 同为后来发现该特化是该调用的最好匹配 所以它是被 函数重载解析过程选中的函数 模板显式特化不会自动进入候选函数集 只有模板实参推演成功的模板特化才被考虑 例如 // 函数模板定义 template Type min( Type, Type ) { /* ... */ } // Type == double 的显式特化 template<> double min( double,double ); void manip( int ii, double dd ) { // 错误: 模板实参推演失败 // 该调用没有候选函数 min( dd, ii ); } 这里函数模板 min()将针对实参 double 进行特化 但是 这个特化并没有进入候选函数 433 第十章 函数模板 集 manip()中对 min()调用时 模板实参推演会失败 因为从每个函数实参为 Type 推演出来 的模板实参是不同的 对于第一个实参 为 Type 推演出来的类型是 double 而对于第二个 实参 为 Type 推演出来的类型是 int 因为模板实参推演失败 所以没有实例进入候选函数 集 并且特化 min(double,double)也被忽略 又因为该调用没有其他的候选函数 所以调 用是错误的 正如 10.6 节所提到的 一个普通函数也可以具有与 一个模板的实例化函数 完全匹配 的返回类型和参数表 在下列例子中 函数 min(int,int)只是一个普通函数 而不是函数模 板 min()的特化 你可能还记得 特化声明必须以符号 template<>开始 // 函数模板声明 template T min( T, T ); // 普通函数声明 int min( int, int ) { } 一个函数调用可以与普通函数以及函数模板的实例化函数都匹配 在下列例子中 调用 min(ai[0],99)的两个实参类型都是 int 该调用有两个可行函数 普通函数 min(int,int) 和从函数模板实例化的 带有相同返回类型和参数的函数 int ai[4] = { 22, 33, 44, 55 }; int main() { // 调用普通函数 min( int, int ) min( ai[0], 99 ); } 但是 这样的调用不是二义的 当非模板函数存在时 因为该函数被显式实现 所以它 将被给予更高的优先级 函数重载解析过程为该调用选择普通函数 min(int,int) 一旦某个调用被函数重载解析过程解析为一个普通函数 如果该程序不包含该函数的定 义 也不能回头了 如果不存在函数体 编译器也不能将函数模板实例化 为此函数生成函 数体 取而代之的是产生一个链接时刻错误 在下面的例子中 程序调用了一个普通函数 min(int,int) 但是没有定义它 该程序产生了一个链接错误 // 函数模板 template T min( T, T ) { .... } // 这个普通函数在该程序中没有被定义 int min( int ,int ); int ai[4] = { 22, 33, 44, 55 }; int main() { // 链接错误: min(int, int) 被调用 但是没有被定义 min( ai[0], 99 ); } 定义一个普通函数 让它的返回类型和参数表与另一个从模板实例化的函数的相同 又 有什么用处呢 记住 当调用从模板实例化的函数时 只有有限的类型转换可以被应用在模 板实参推演过程使用的函数实参上 如果声明一个普通函数 则可以考虑用所有的类型转换 来转换实参 这是因为普通函数参数的类型是固定的 让我们看一个例子 它给出了声明一 434 第十章 函数模板 个普通函数的原因 假设我们想定义一个函数模板特化 min(int int)并希望 当用任何整型类型的实参 调用 min()时 无论实参类型是否相同都调用这个函数 因为类型转换上的限制 所以用不同 类型的整型实参的调用不会直接调用函数模板实例 min(int,int) 我们可以通过指定显 式模板实参直接调用该实例 但是 我们更喜欢一个不要求修改每个调用点的方案 通过定 义一个普通函数 无论何时使用整型实参 我们的程序都会调用 min(int,int)的特化版本 而无需我们为每个调用都使用显式模板实参 例如 // 函数模板定义 template Type min( Type t1, Type t2 ) { ... } int ai[4] = { 22, 33, 44, 55 }; short ss = 88; void call_instantiation() { // 错误: 这个调用没有候选函数 min( ai[0], ss ); } // 普通函数 int min( int a1, int a2 ) { min( a1, a2 ); } int main() { call_instantiation(); // 调用普通函数 min( ai[0], ss ); } 在 call_instantiation()中的调用 min(ai[0],ss)没有候选函数 因为 想要从函数模板 min() 生成一个候选函数肯定要失败从函数实参将为 Type 推演出不同的模板实参 所以该调用是 错误的 但是对于 main()中的调用 min(ai[0],ss)来说 普通函数 min(int,int)的声明是 可见的 这个普通函数是该调用的可行函数 第一个实参的类型与相应参数的类型精确匹配 第二个实参可以用提升转换为相应参数的类型 由于该普通函数是第二个调用的可行函数集 中的惟一函数 所以它将被选中 我们已经了解当涉及到同名的函数模板实例 函数模板特化以及普通函数时 函数重载 解析是怎样进行的 现在让我们来总结一下 对于一个调用 考虑普通函数和函数模板的函 数重载解析步骤 1 生成候选函数集 考虑与函数调用同名的函数模板 如果对于该函数调用的实参 模板实参推演能够成功 则实例化一个函数模板 或者对于推演出来的模板实参存在一个模板特化 则该模板特化就 是一个候选函数 2 生成可行函数集 如 9.3 节所描述 只保留候选函数集中可以用函数调用实参调用的函数 3 对类型转换划分等级 如 9.3 节中描述 435 第十章 函数模板 a 如果只选择了一个函数 则调用该函数 b 如果该调用是二义的 则从可行函数集中去掉函数模板实例 4 只考虑可行函数集中的普通函数 完成重载解析过程 如 9.3 节中描述 a 如果只选择了一个函数 则调用该函数 b 否则 该调用是二义的 让我们一步步地来看一个例子 下面是两个声明 一个函数模板声明 一个带有两个 double 型实参的普通函数 template Type max( Type, Type ) { .... } // 普通函数 double max( double, double ); 下面是三个 max()调用 你能分辨出每个调用分别会调用哪个实例吗 int main() { int ival; double dval; float fd; // 向 ival, dval, 和 fd 赋某些值 max( 0, ival ); max( 0.25, dval ); max( 0, fd ); } 让我们按顺序查看每个调用 max(o,ival) 两个实参的类型都是 int 对该调用存在两个候选函数 函数模板 实例 max(int,int)以及普通函数 max(double,double) 用于函数模板实例对函数 实参来说是精确匹配的 所以它将是被调用的函数 max(0.25,dval) 这两个实参都是 double 型 对该调用存在两个候选函数 函 数模板实例 max(double,double)以及普通函数 max(double,double) 因为调用 与两个函数都完全匹配 所以该调用存在二义 根据规则 3b 可知在这种情况下应 选择普通函数 max(0,fd) 实参的类型分别是 int 和 float 对该调用只存在一个候选函数 普通 函数 max(double,double) 因为从两个函数实参为 Type 推演出的模板实参不是 一种类型 所以模板实参推演会失败 故也没有模板实例进入候选函数集 因为存 在类型转换能把函数实参转换成相应函数参数的类型 该普通函数是个可行函数 因此 选择普通函数 如果该普通函数没有被定义 则该调用将是个错误 如果我们已经为 max()定义了第二个普通函数 又会怎么样呢 例如 template T max( T, T ) { .... } // 两个普通函数 char max( char, char ); double max( double, double ); 436 第十章 函数模板 第三个调用的解析会不同吗 是的 int main() { float fd; // 解析为哪个函数? max( 0, fd ); } 规则 3b 说明 因为该调用是二义的 所以只考虑普通函数 这些函数都没有被选为最佳 可行函数 因为在实参上的类型转换对两个函数都是一样的不好 两个实参都要求标准转换 以便匹配可行函数中的两个相应的参数 所以该调用是二义的 将被编译器标记为错误 练习 10.11 让我们回到前面给出的例子 template Type max( Type, Type ) { .... } double max( double, double ); int main() { int ival; double dval; float fd; max( 0, ival ); max( 0.25, dval ); max( 0, fd ); } 下面的函数模板特化被加入到全局域的声明集中 template <> char max( char, char ) { .... } 重新考虑 main()中的函数调用 并为每个调用列出候选函数和可行函数 假定下面的函 数调用被加到 main()中 则该调用将被解析为哪个函数 为什么 int main() { // ... max (0, 'J' ); } 练习 10.12 假设有下列模板定义和特化 以及变量 函数声明的集合 int i; unsigned int ui; char str[24]; int ia[24]; template T calc( T*, int ); template T calc( T, T ); template<> char calc( char*, int ); double calc( double, double ); 437 第十章 函数模板 指出下列每个调用将会调用哪个模板实例或函数 为每个调用 列出候选函数 可行函 数 并解释选择最佳可行函数的原因 (a) calc( str, 24 ); (d) calc( i, ui ); (b) calc( ia, 24 ); (e) calc( ia, ui ); (c) calc( ia[0], i ); (f) calc( &i, i ); 10.9 模板定义中的名字解析 在模板定义中有些结构在两个模板实例之间有不同的意义 而另外一些结构在模板的所 有实例之间意义相同 这取决于该结构是会涉及模板参数 例如 template Type min( Type* array, int size ) { Type min_val = array[0]; for ( int i = 1; i < size; ++i ) if ( array[i] < min_val ) min_val = array[i]; print( "Minimum value found: " ); print( min_val ); return min_val; } 在 min()中 array 和 min_val 的类型取决于模板被实例化时取代 Type 的实际类型 而 size 的类型总是 int 与模板参数的类型无关 array 和 min_val 的类型随不同的模板实例而不同 因此 我们说这些变量的类型依赖于模板参数 depend on a template parameter 而 size 的 类型则不依赖于模板参数 因为不知道 min_val 的类型 所以当 min_val 出现在表达式中时 究竟哪个操作将会被 用到也是未知的 例如 函数调用 print(min_val)将会调用哪个 print()函数 应该是 int 类 型的 print()函数或是 float 类型的 print()函数 会不会因为没有 print()函数可以用 min_val 的 类型的实参来调用而使得这个调用是错误的 只有当实例化该模板 知道了 min_val 的实际 类型时 我们才能回答这些问题 因此我们也称调用 print(min_val)依赖于模板参数 在 min()中 不依赖于模板参数的结构就没有这样的问题 例如 调用 print("Minimum value found")总知道应该调用哪个函数 这个函数被用来输出字符串 被调用的 print()函数 不会随着模板实例的不同而不同 因此 我们称该调用不依赖于模板参数 正如我们在第 7 章所了解的 在 C++中 函数必须在调用前被声明 在模板定义中 被 调用的函数必须在模板定义出现之前被声明吗 在前面的例子中 在 min()的模板定义中 函 数 print()必须在调用之前先声明吗 答案取决于我们引用了哪种名字 不依赖于模板参数的 结构必须在使用之前先声明 因此前面给出的函数模板 min()的定义是不正确的 因为调用 print( "Minimum value found: " ); 不依赖于模板参数 所以针对字符串的函数 print()必须在被模板定义使用它之前先被声 明 为了解决这个问题 print()函数的声明必须在 min()的定义之前给出 如下所示 438 第十章 函数模板 // ---- primer.h ---- // 这个声明是必需的 // print( const char * ) 在 min() 中被调用 void print( const char* ); template Type min( Type* array, int size ) { // .... print( "Minimum value found: " ); print( min_val ); return min_val; } 另一方面 调用 print(min_val)使用的 print()函数的声明则是不需要的 因为我们还不 知道要寻找的是哪个 print() 只有当 min_val 的类型是已知的时候 才可能知道应该调用哪 个 print()函数 那么应该在什么时候声明 print(min_val)调用的 print()函数呢 必须在实例化模板之前 声明 例如 #include void print( int ); int ai[4] = { 12, 8, 73, 45 }; int main() { int size = sizeof(ai) / sizeof(int); // min( int*, int ) 的实例 min( &ai[0], size ); } 函数 main()调用函数模板实例 min(int*,int) 在 min()的这个实例中 Type 被 int 取代 而变量 min_val 的类型是 int 所以 print(min_val)调用一个可以用 int 类型的实参来调用的 函数 在实例化 min(int*,int)的时候 我们知道 print()函数的第二个调用有一个 int 型的实 参 也正是在这个时候 要求可以用 int 型实参调用的 print()函数必须可见 在我们的例子中 被选择的函数是 print(int) 如果函数 print(int)在 min(int*,int)被实例化之前没有被声 明 则该实例化会导致编译时刻错误 所以 模板定义中的名字解析分两个步骤进行 首先 不依赖于模板参数的名字在模板 定义时被解析 其次 依赖于模板参数的名宇在模板被实例化时被解析 你可能会问 为什 么要分两步呢 为什么不是所有的名字都在模板被实例化时被解析呢 如果我们是函数模板的设计者 我们可以控制模板定义中的名字解析方式 假设函数模 板 min()位于一个函数库中 该库还定义了其他一些模板和函数 我们希望 min()的实例在所 有可能的时候都使用库中的其他组件 在前面的例子中 库的接口定义位于头文件 中 函数 print(const char*)的声明和函数模板 min()的定义都是库的接口的一部分 我们希 望 min()的实例调用该库提供的 print()函数 名字解析的第一步保证了这件事情 当用在模板 定义中的名字不依赖于模板参数时 此名字肯定会指向库中定义的另一个组件——即 它会 439 第十章 函数模板 指向和函数模板定义在一起 被打包在头文件中的声明 事实上 函数模板的设计者必须确保为模板定义中用到的 所有不依赖于模板参数的名 字提供声明 如果用在模板定义中的名字不依赖于模板参数 并且在定义该模板时没有找到 该名字的声明 则模板定义是错误的 当模板被实例化时 编译器就不再考虑这样的错误 例如 // ---- primer.h ---- template Type min( Type* array, int size ) { Type min_val = array[0]; // ... // 错误: 没有找到 print ( const char* ) print( "Minimum value found: " ); // ok: 依赖于模板参数 print( min_val ); // ... } // ---- user.C ---- #include // print( const char* ) 的这个声明被忽略 void print( const char* ); void print( int ); int ai[4] = { 12, 8, 73, 45 }; int main() { int size = sizeof(ai) / sizeof(int); // min( int*, int ) 的实例 min( &ai[0], size ); } 在 user.C 中的 print(const char*)的声明 在模板定义出现的地方是不可见的 但是 这样的声明在模板 min(int*,int)被实例化时是可见的 但该声明并不被调用 print("minimum value found:")所考虑 因为该调用不依赖于模板参数 除非模板定义中的结构依赖于模板 参数 否则一个名字在模板定义的上下文中被解析 此解析过程不会在该模板实例化的上下 文中被重新考虑 因此 确保被用在模板定义中的名字的声明和模板定义被正确地包含为库 接口的一部分 这是模板设计者的责任 让我们换个思路 假设该库是由其他人编写的 我们是头文件中的定义的用 户 有这样一种情况 我们希望当程序在实例化库中的模板时考虑程序中定义的对象和函数 例如 假设我们的程序定义了一个称为 SmallInt 的类 我们希望实例化库中的函 数 min() 以获得 SmallInt 型的对象的数组中的最小值 当函数模板 min()被 SmallInt 型对象的数组实例化时 Type 的模板实参是 class 类型的 SmallInt 这意味着 在 min()的实例中 min_val 的类型是 SmallInt 在 min()的实例中 调 用 print(min_val)应该被解析为哪个函数 440 第十章 函数模板 // ---- user.h ---- class SmallInt { /* ... */ }; void print( const SmallInt & ); // ---- user.C ---- #include #include "user.h" SmallInt asi[4]; int main() { // 设置 asi 的元素 // min( SmallInt*, int ) 的实例 int size = sizeof(asi) / sizeof(SmallInt); min( &asi[0], size ); } 对 我们希望考虑我们的函数 print(const SmallInt&) 只考虑在库中定义的 函数是远远不够的 名字解析的第二步保证这样的事情可以发生 当用在模板定义中的名字 依赖于模板参数时 在实例上下文中声明的名字被考虑 所以 对于函数模板中的该操作 如果模板实参是 SmallInt 型 那么可以处理 SmallInt 型的对象的函数肯定会被考虑 在源代码中模板被实例化的位置被称为模板的实例化点 point of instantiation 知道模 板的实例化点是很重要的 因为它决定了对依赖于模板参数的名字所考虑的声明 函数模板 的实例化点总是在名字空间域中 并且跟在引用该实例的函数后 例如 min(SmallInt*,int) 的实例化点就紧跟在名字空间域中的函数 main()后 // ... int main() { // ... // 使用 min( SmallInt*, int ) min( &asi[0], size ); } // min( SmallInt*, int ) 的实例化点 // 好像实例定义如下出现 SmallInt min( SmallInt* array, int size ) { /* ... */ } 但是 如果在一个源文件中 一个模板实例不只被使用一次 又该怎么办呢 实例化点 在哪儿呢 可能你会问 为什么这很重要 在我们的例子中 它的重要性是因为函数 print(const SmallInt&)的声明必须出现在 min(SmallInt*,int)的实例化点之前 例如 #include void another(); SmallInt asi[4]; int main() { // 设置 asi 的元素 int size = sizeof(asi) / sizeof(SmallInt); min( &asi[0], size ); another(); // ... 441 第十章 函数模板 } // 实例化点在这儿? void another() { int size = sizeof(asi) / sizeof(SmallInt); min( &asi[0], size ); } // 还是这儿? 当模板实例要多次使用时 在每个使用该实例的函数定义之后都有一个实例化点 编译器 自由选择这些实例化点之一来真正实例化该函数模板 这意味着在组织代码时 我们必须小心 地把解析依赖于模板参数的名字所需要的声明放置在模板的第一个实例化点之前 因此 在模 板的任何一个实例被使用之前 应该头文件中给出所有必需的声明 并包含这个头文件 例如 #include // user.h 含有实例所需的声明 #include "user.h" void another(); SmallInt asi[4]; int main() { // ... } // min( SmallInt*, int ) 的第一个实例化点 void another() { // ... } // min( SmallInt*, int ) 的第二个实例化点 如果不只在一个文件中使用模板实例 该怎样办呢 例如 如果函数 another()函数 main()在不同的文件中 该怎样办呢 那么在每个使用该模板的实例的文件中都存在一个实 例化点 编译器自由选择这些文件中的任一个实例化点来实例化该函数模板 所以在组织代 码时 我们必须也要小心地把头文件 user.h 包含在每个使用该函数模板实例的文件中 这保 证 min(SmallInt*,int)的实例指向我们的函数 print(const SmallInt&) 这是我们所期望的 与编译器选择哪个实例化点无关 练习 10.13 列出模板定义中名字解析的两个步骤 解释第一步如何解决库设计者关心的事情 以及 第二步怎样提供模板用户所需要的灵活性 练习 10.14 在 max(LongDouble*,SIZE)实例中的名字 display 和 SIZE 引用哪个声明 // ---- exercise.h ---- void display( const void* ); typedef unsigned int SIZE; template 442 第十章 函数模板 Type max( Type* array, SIZE size ) { Type max_val = array[0]; for ( SIZE i = 1; i < size; ++i ) if ( array[i] > max_val ) max_val = array[i]; display( "Maximum value found: " ); display( max_val ); return max_val; } // ---- user.h ---- class LongDouble { /* ... */ }; void display( const LongDouble & ); void display( const char * ); typedef int SIZE; // ---- user.C ---- #include #include "user.h" LongDouble ad[7]; int main() { // 设置 ad 的元素 // max( LongDouble*, SIZE ) 的实例化 SIZE size = sizeof(ad) / sizeof(LongDouble); max( &ad[0], size ); } 10.10 名字空间和函数模板 与其他全局域定义一样 函数模板定义也可以被放在名字空间中 关于名字空间的讨 论见 8.5 节和 8.6 节 这种模板定义的意义与在全局域中定义的一样 除了该模板的名字 被隐藏在名字空间中 当在名字空间之外使用该模板时 该模板名必须被限定 或提供一个 usign 声明 // ---- primer.h ---- namespace cplusplus_primer { // 模板定义被隐藏在名字空间中 template Type min( Type* array, int size ) { /* ... */ } } // ---- user.C ---- #include int ai[4] = { 12, 8, 73, 45 }; int main() { int size = sizeof(ai) / sizeof(ai[0]); 443 第十章 函数模板 // 错误: 没有找到函数 min() min( &ai[0], size ); using cplusplus_primer::min; // using 声明 // ok: 指向名字空间 cplusplus_primer 中的 min() min( &ai[0], size ); } 如果我们的程序使用了一个在名字空间中定义的模板 而且我们希望为其提供一个特化 又会怎么样 关于模板显式特化在 10.6 节介绍 例如 我们想用名字空间 cplusplus_ primer 中定义的模板 min()来找到 SmallInt 型的对象数组中的最小值 但是 我们意识到 在 名字空间 cplusplus_primer 中提供的模板定义不能完全奏效 函数模板中的比较操作如下 if ( array[i] < min_val ) 该语句用小于 < 操作符比较两个 SmallInt 型的类对象 除非为类 SmallInt 定义了一个 重载的 operator<() 否则该操作符不能被应用在两个类对象上 我们将在 15 章看到怎样定 义重载操作符 假设我们为 min()函数模板定义一个特化 以便用一个名为 compareLess() 的函数找到 SmallInt 类对象的数组中的最小值 下面是 compareLess()函数的声明 // SmallInt 对象的比较函数 // 如果 param1 小于 parm2, 返回 true bool compareLess( const SmallInt &parm1, const SmallInt &parm2 ); 这个函数的定义看起来会是什么样的呢 为了回答这个问题 我们需要更详细地看看我 们的 SmallInt 的定义 这个 SmallInt 可以用来一些对象 能够存放定义与 8 位 unsigned char 范围相同的值 即 0 255 它的附加功能是能够捕捉到上溢和下溢错误 除此之外 我们还 希望它的工作方式与 unsigned char 相同 类 SmallInt 的定义看起来如下 class SmallInt { public: SmallInt( int ival ) : value( ival ) { } friend bool compareLess( const SmallInt &, const SmallInt & ); private: int value; // 数据成员 }; 在这个类定义中 有些事情我们应该讨论一下 首先 该类有一个私有数据成员 value 它是存储 SmallInt 型对象值的数据成员 该类还包含一个构造函数 // 类 SmallInt 的构造函数 SmallInt( int ival ) : value( ival ) { } 该构造函数有一个参数 ival 它执行的惟一动作是用参数 ival 的值初始化类数据成员 value 现在 我们可以回答前面的问题了 函数 compareLess()怎样定义 该函数将比较它的两 个 SmallInt 型参数的 value 数据成员 如下 // 如果 parm1 小于 parm2, 则返回 true bool compareLess( const SmallInt &parm1, const SmallInt &parm2 ) { return parm1.value < parm2.value; } 444 第十章 函数模板 但是要注意 数据成员 value 是 SmallInt 类的私有数据成员 这个全局函数怎样引用该 私有成员而不破坏类 SmallInt 的封装化且不会引起编译错误呢 如果看看 SmallInt 的定义 你会注意到这个类定义把全局函数 compareLess()声明为 friend 当一个函数是一个类的 friend 时 它就可以引用该类的私有成员 譬如我们的函数 compareLess()在 15.2 节中对类的 fiend 有进一步介绍 现在我们已经准备好为 min()定义我们的模板特化了 它如下使用 compareLess()函数 // 针对 SmallInt 对象数组的 min() 特化 template<> SmallInt min( SmallInt* array, int size ) { SmallInt min_val = array[0]; for ( int i = 1; i < size; ++i ) // 使用函数 compareLess() 比较 if ( compareLess( array[i], min_val ) ) min_val = array[i]; print( "Minimum value found: " ); print( min_val ); return min_val; } 我们应该在哪里声明这个特化 看看下面的做法怎么样 // ---- primer.h ---- namespace cplusplus_primer { // 模板定义被隐藏在名字空间中 template Type min( Type* array, int size ) { /* ... */ } } // ---- user.h ---- class SmallInt { /* ... */ }; void print( const SmallInt & ); bool compareLess( const SmallInt &, const SmallInt & ); // ---- user.C ---- #include #include "user.h" // 错误: 不是 cplusplus_primer::min() 的特化 template<> SmallInt min( SmallInt* array, int size ) { /* ... */ } // ... 不幸的是 该代码不能完成任务 函数模板的显式特化声明必须被声明在该通用模板被 定义的名字空间内 因此 我们必须在名字空间 cplusplus_primer 中定义 min()的特化 在我 们的程序中 有两种实现它的方式 请回忆一下 由于名字空间的定义可以是非连续的 所以我们可以重新打开名字空间 cplusplus_primer 的定义并加上该特化的定义 如下 // ---- user.C ---- #include #include "user.h" 445 第十章 函数模板 namespace cplusplus_primer { // cplusplus_primer::min() 的特化 template<> SmallInt min( SmallInt* array, int size ) { /* ... */ } } SmallInt asi[4]; int main() { // 用 set() 成员函数设置 asi 的元素 using cplusplus_primer::min; // using declaration int size = sizeof(asi) / sizeof(SmallInt); // min(SmallInt*,int) 的实例化 min( &asi[0], size ); } 或者我们可以用 在名字空间定义之外定义任何名字空间成员 的方式定义该特化 通 过用外围名字空间名来限定修饰名字空间成员名 // ---- user.C ---- #include #include "user.h" // cplusplus_primer::min() 的特化 // 此特化的名字被限定修饰 template<> SmallInt cplusplus_primer:: min( SmallInt* array, int size ) { /* ... */ } // ... 所以 作为包含模板定义的库的用户 如果我们想为库中的模板提供特化 那么我们就 必须确保它们的定义被合适地放置在含有原始模板定义的名字空间内 练习 10.15 现在我们把练习 10.14 中给出的头文件的内容放到名字空间 cplusplus_primer 中 怎样改变函数 main()使其能够实例化位于名字空间 cplusplus_primer 中的函数模板 max() 练习 10.16 再次参考练习 10.14 已知头文件的内容被放在名字空间 cplusplus_primer 中 我们想为类 LongDouble 对象的数组定义函数模板 max()的特化 并且让该模板特化使用如下 定义的函数 compareGreater() 来比较两个 LongDouble 型的对象 // 比较两个 LongDouble 对象的函数 // 如果 parm1 大于 parm2, 则返回 true bool compareGreater( const LongDouble &parm1, const LongDouble &parm2 ); 类 LongDouble 的定义如下 class LongDouble { 446 第十章 函数模板 public: LongDouble(double dval) : value(dval) { } void set(double dval) { value = dval; } friend bool compareGreater( const LongDouble &, const LongDouble & ); private: double value; }; 给出函数 compareGreater()以及使用该函数的 max()特化的定义 写一个用于设置数组 ad 元素的 main()函数 然后调用 max()的特化取得 ad 中的最大值 初始化数组 ad 的元素的值应 该通过读取标准输入 cin 获得 10.11 函数模板示例 本节将给出一个程序示例 用来说明怎样定义和使用函数模板 这个示例定义了一个函 数模板 sort() 它对数组的元素进行排序 数组本身由在 2.5 节介绍的 Array 类模板表示 因 此 函数模板 sort()可以被用来排序任意类型元素的数组 我们在第 6 章中看到 C++标准库定义了一个容器类型被称为 Vector 它的行为和 2.5 节 定义的 Array 非常相像 第 12 章将介绍泛型算法 它可以处理第 6 章描述的容器类型 其中 一个算法被称为 sort() 它可以被用来排序 vector 的内容 在本节中 我们将定义自己的 泛 型 sort()算法 以处理我们的 Array 类 你在本节中所看到的是 C++标准库中的算法的简化 版本 我们针对 Array 类模板的 sort()函数模板定义如下 #include "Array.h" template void sort( Array &array, int low, int high ) { if ( low < high ) { int lo = low; int hi = high + 1; elemType elem = array[lo]; for (;;) { while ( min( array[++lo], elem ) != elem && lo < high ) ; while ( min( array[--hi], elem ) == elem && hi > low ) ; if (lo < hi) swap( array, lo, hi ); else break; } swap( array, low, hi ); sort( array, low, hi-1 ); sort( array, hi+1, high ); } } 447 第十章 函数模板 函数 sort()使用了两个辅助函数 min()和 swap() 这两个函数都被定义为函数模板 而 且能够处理可用来实例化 sort()的全部实参类型 min()被定义为函数模板 以便我们可以找 到任意类型的两个数组元素的最小值 template Type min( Type a, Type b ) { return a < b ? a : b; } swap()被定义为函数模板 以便我们可以交换任意类型的两个数组元素 #include "Array.h" template void swap( Array &array, int i, int j ) { elemType tmp = array[ i ]; array[ i ] = array[ j ]; array[ j ] = tmp; } 为了确保我们的 sort()函数模板能够真正地工作 我们需要在数组被排序后显示数组的内 容 因为 display()函数必须能够处理 Array 类模板被实例化后的任何数组 所以我们也必须 把 display()定义为函数模板 #include template void display( Array &array ) { // display format: < 0 1 2 3 4 5 > cout << "< "; for ( int ix = 0; ix < array.size(); ++ix ) cout << array[ix] << " "; cout << ">\n"; } 在这个例子中 我们使用包含编译模式 并把函数模板放在头文件 Array.h 中 跟在 Array 类模板后面 下一步是写一个函数练习这些函数模板 依次被传递给函数 sort()的数组为 double 型的 数组 int 型的数组和 string 型的数组 下面是程序 #include #include #include "Array.h" double da[10] = { 26.7, 5.7, 37.7, 1.7, 61.7, 11.7, 59.7, 15.7, 48.7, 19.7 }; int ia[16] = { 503, 87, 512, 61, 908, 170, 897, 275, 653, 448 第十章 函数模板 426, 154, 509, 612, 677, 765, 703 }; string sa[11] = { "a", "heavy", "snow", "was", "falling", "when", "they", "left", "the", "police", "station" }; int main() { // 调用构造函数初始化 arrd Array arrd( da, sizeof(da)/sizeof(da[0]) ); // 调用构造函数初始化 arri Array arri( ia, sizeof(ia)/sizeof(ia[0]) ); // 调用构造函数初始化 arrs Array arrs( sa, sizeof(sa)/sizeof(sa[0]) ); cout << "sort array of doubles (size == " << arrd.size() << ")" << endl; sort( arrd, 0, arrd.size()-1 ); display(arrd); cout << "\sort array of ints (size == " << arri.size() << ")" << endl; sort( arri, 0, arri.size()-1 ); display(arri); cout << "\sort array of strings (size == " << arrs.size() << ")" << endl; sort( arrs, 0, arrs.size()-1 ); display(arrs); return 0; } 编译并运行该程序 产生下列输出 数组的输出已经被手工调整以适应篇幅 sort array of doubles (size == 10) < 1.7 5.7 11.7 14.9 15.7 19.7 26.7 37.7 48.7 59.7 61.7 > sort array of ints (size == 16) < 61 87 154 170 275 426 503 509 512 612 653 677 703 765 897 908 > sort array of strings (size == 11) < "a" "falling" "heavy" "left" "police" "snow" "station" "the" "they" "was" "when" > 在 C++标准库定义的泛型算法中 在第 12 章中 你还会找到一个 min()函数和一个 swap() 函数 在第 12 章中我们将了解怎样在程序中使用它们 11 异 常 处 理 异常处理是一种允许两个独立开发的程序组件在程序执行期间遇到程序不正常的 情况 称为异常 exception 时 相互通信的机制 在本章中 我们将首先了解 怎样在程序异常出现的位置产生 raise 或抛出 throw 异常 然后 我们再看 一看怎样用 try 块把处理代码 或称作 catch 子句 和一组程序语句关联起来 并 了解 catch 子句怎样处理异常 然后我们将会介绍异常规范 它是一种把一组异常 和一个函数声明关联起来的机制 用于保证该函数不会抛出其他类型的异常 在本 章最后 我们还将讨论程序使用异常时的一些注意事项 11.1 抛出异常 异常 Exception 是程序可能检测到的 运行时刻不正常的情况 如被 0 除 数组越界 访问或空闲存储内存耗尽等等 这样的异常存在于程序的正常函数之外 而且要求程序立即 处理 C++提供了一些内置的语言特性来产生 raise 并处理异常 这些语言特性将激活了 一种运行时刻机制 通过这种机制可在 C++程序的两个无关 常常是独立开发 的部分进行 异常通信 C++程序中出现异常时 检测到异常的程序段可以通过产生 raise 或抛出 throw 异 常来通知 异常已经发生 为了了解在 C++中是怎样抛出异常的 我们来重新实现 4.15 节 给出的 iStack 类 这次我们用异常来指出在栈的处理中不正常的情况 类 iStack 的定义看起 来是这样的 #include class iStack { public: iStack( int capacity ) : _stack( capacity ), _top( 0 ) { } bool pop( int &top_value ); bool push( int value ); bool full(); bool empty(); 450 第十一章 异常处理 void display(); int size(); private: int _top; vector< int > _stack; }; 这里的栈用一个 int 型的 vector 来实现 当我们创建一个 iStack 对象时 iStack 的构造函 数就会创建一个 int 型的 vector 其长度由初始值指定 该长度是 iStack 对象可以含有的元素 的最大个数 例如 下面的代码创建了一个名为 myStack 的 iStack 对象 它可以含有 20 个 int 型的值 iStack myStack(20); 处理 myStack 时会出现什么样的错误呢 下面是 iStack 类可能会遇到的不正常情况 1 要求一个 pop()操作 但栈却是空的 2 要求一个 push()操作 但栈却是满的 如果这些不正常的情况都应该用异常通知给操纵 iStack 对象的函数 那么我们从哪儿开 始呢 首先 必须定义可以被抛出的异常 在 C++中 异常往往用类 class 来实现 虽然我 们要到第 13 章才能完整地介绍类 但是在这里我们还是跟随 iStack 类定义了两个被用作异 常的类 并把这些类定义放在头文件 stackExcp.h 中 // stackExcp.h class popOnEmpty { /* ... */ }; class popOnFull { /* ... */ }; 在第 19 章我们将更详细地讨论 class 类型的异常 并讨论由 C++标准库提供的异常类继 承层次 我们必须要修改 pop()和 push()成员函数的定义 以便让它们可以抛出新定义的异常 抛 出异常可通过 throw 表达式来实现 throw 表达式看起来非常像 return 语句 throw 表达式由 关键字 throw 后面跟一个表达式构成 该表达式的类型是被抛出异常的类型 在 pop()中的 throw 表达式是什么样子的呢 我们来试一下 // 喔! 不是十分正确 throw popOnEmpty; 不幸的是 这不完全正确 异常是个对象 pop()必须抛出一个 class 类型的对象 在 throw 表达式中的表达式不能只是一个类型 为创建一个 class 类型的对象 我们需要调用该类的构 造函数 调用一个构造函数的 throw 表达式又是什么样的呢 下面是 pop()中的 throw 表达式 // 表达式是一个构造函数调用 throw popOnEmpty(); 该 throw 表达式创建一个 popOnEmpty 类型的异常对象 成员函数 pop()和 push()被定义为返回一个 bool 型的值 返问 true 表示该操作成功 返 回 false 表示失败 因为现在用异常表示 pop()和 push()操作的失败 那么这些函数的返回值 451 第十一章 异常处理 就不是必要的了 因而我们把这些成员函数的返回类型定义为 void 例如 class iStack { public: // ... // 不再返回一个值 void pop( int &value ); void push( int value ); private: // ... }; 使用 iStack 类的函数现在会假设 除非抛出异常 否则每件事情都是正常的 它们不再 需要测试成员函数 pop()和 push()的返回值来了解操作是否成功 我们将在下两节了解怎样定 义处理异常的函数 现在我们准备给出 iStack 的成员函数 pop()和 push()的新实现代码 #include "stackExcp.h" void iStack::pop( int &top_value ) { if ( empty() ) throw popOnEmpty(); top_value = _stack[ --_top ]; cout << "iStack::pop(): " << top_value << endl; } void iStack::push( int value ) { cout << "iStack::push( "iStack::push( " << value << " ) \n"; if ( full() ) throw pushOnFull(); stack[ _top++ ] = value; } 虽然异常往往是 class 类型的对象 但是 throw 表达式也可以抛出任何类型的对象 例如 虽然很不常见 在下面的代码例子中 函数 mathFunc()抛出一个枚举类型的异常对象 下 面是合法的 C++代码 enum EHstate { noErr, zeroOp, negativeOp, severeError }; int mathFunc( int i ) { if ( i == 0 ) throw zeroOp; // 枚举类型的异常 // 否则的话 继续正常处理流程 452 第十一章 异常处理 练习 11.1 下面的 throw 表达式哪些是错误的 为什么 对于合法的 throw 表达式 指出被抛出的 异常的类型 (a) class exceptionType { }; throw exceptionType { }; (b) int excpObj; throw excpObj; (c) enum mathErr { overflow, underflow, zeroDivide }; throw zeroDivide(); (d) int *pi = &excpObj; throw pi; 练习 11.2 2.3 节定义的类 IntArray 有一个成员操作符函数 operator[]() 它用 assert()指示索引超越 数组的边界 改变 operator[]()的定义 使其在该情况下抛出异常 定义一个异常类用作被抛 出异常的类型 11.2 try 块 下面的小程序用到了我们的 iStack 类 以及上节定义的 pop()和 push()成员函数 在 main() 中的 for 循环迭代 50 次 它把 3 的倍数值 3 6 9 等等 压入到栈中 当遇到 4 的倍数时 如 4 8 12 等等 显示栈的内容 当值是 10 的倍数时 如 10 20 30 等等 则把栈的最后 一项弹出 然后再次显示栈的内容 怎样改变 main()使其能够处理由 iStack 成员函数抛出的 异常呢 #include #include "iStack.h" int main() { iStack stack( 32 ); stack.display(); for ( int ix = 1; ix < 51; ++ix ) { if ( ix % 3 == 0 ) stack.push( ix ); if ( ix % 4 == 0 ) stack.display(); if ( ix % 10 == 0) { int dummy; stack.pop( dummy ); stack.display(); } } return 0; 453 第十一章 异常处理 } try块 try block 必须包围能够抛出异常的语句 try 块以关键字 try 开始 后面是花括 号括起来的语句序列 在 try 块之后是一组处理代码 被称为 catch 子句 try 块把语句分成 组 并将其与相应地处理这些语句可能抛出的异常的处理语句相关联 我们应该把 try 块放 在 main()中的什么地方来处理 popOnEmpty 和 pushOnFull 异常 我们尝试一下 for ( int ix = 1; ix < 51; ++ix ) { try { // pushOnFull 异常的 try 块 if ( ix % 3 == 0 ) stack.push( ix ); } catch ( pushOnFull ) { ... } if ( ix % 4 == 0 ) stack.display(); try { // popOnEmpty 异常的 try 块 if ( ix % 10 == 0 ) { int dummy; stack.pop( dummy ); stack.display(); } } catch ( popOnEmpty ) { ... } } 我们实现的程序能够工作正常 但是 它的组织结构把异常处理和程序正常处理混在一 起 因而不太理想 毕竟 异常是程序的非正常事件出现的情况 应把处理程序异常的代码 与栈的正常操作的实现分离开 因为我们相信这个策略会使得代码更易于跟随和维护 下面 是较好的方案 try { for ( int ix = 1; ix < 51; ++ix ) { if ( ix % 3 == 0 ) stack.push( ix ); if ( ix % 4 == 0 ) stack.display(); if ( ix % 10 == 0 ) { int dummy; stack.pop( dummy ); stack.display(); } } } catch ( pushOnFull ) { ... } catch ( popOnEmpty ) { ... } 与 try 块相关联的是两个 catch 子句 它们能够处理 pushOnFull 和 popOnEmpty 异常 这 两个异常可能会被 try 块中调用的 iStack 的成员函数 pop()和 push()抛出 每个 catch 子句在 454 第十一章 异常处理 括号中指定了它所处理的异常的类型 处理异常的代码被放在 catch 子句的复合语句中 在 花括号之间 我们将在下一节更详细地探讨 catch 子句 在我们的例子中 程序的控制流是下列几种情况之一 1 如果没有异常发生 则执行 try 块中的代码 和 try 块相关联的处理代码被忽略 程 序 main()返回 0 2 如果在 for 循环的第一个 if 语句中调用的成员函数 push()抛出一个异常 则 for 循环 的第二个和第三个 if 语句被忽略 该 for 循环和 try 块被退出 执行 pushOnFull 类型异常的 处理代码 3 如果在 for 循环的第三个 if 语句中调用的成员函数 pop()抛出一个异常 则针对 display() 的调用被忽略 for 循环和 try 块被退出 执行 popOnEmpty 类型异常的处理代码 当某条语句抛出异常时 跟在该语句后面的语句将被跳过 程序执行权被转交给处理异 常的 catch 子句 如果没有 catch 子句能够处理该异常 则程序执行权又将被转交给 C++标准 库中定义的函数 terminate() 我们将在下一节讨论函数 terminate() try块可以包含任何 C++语句——表达式以及声明 一个 try 块引入一个局部域 在 try 块内声明的变量不能在 try 块外被引用 包括在 catch 子句中 例如 我们可以重写函数 main() 使得变量 stack 的声明出现在 try 块中 在这种情况下 在 catch 子句中不能引用 stack int main() { try { iStack stack( 32 ); // ok: 在 try 块中声明 stack.display(); for ( int ix = 1; ix < 51; ++ix ) { // 同上 } } catch ( pushOnFull ) { // 这里不能引用 stack } catch ( popOnEmpty ) { // 这里不能引用 stack } // 这里不能引用 stack return 0; } 我们也可以声明整个包含在 try 块中的函数 在这种情况下 我们不是把 try 块放在函数 定义的内部 而是把函数体整个包含在一个函数 try 块 function try block 中 这种组织结 构把程序的正常处理代码和异常处理代码分离得最为清楚 例如 int main() try { iStack stack( 32 ); stack.display(); for ( int ix = 1; ix < 51; ++ix ) { // 与以前相同 455 第十一章 异常处理 } return 0; } catch (pushOnFull) { // 这里不能引用 stack } catch ( popOnEmpty ) { // 这里不能引用 stack } 注意 关键字 try 在函数体的开始花括号之前 catch 子句列在函数体结束花括号之后 通过这种代码组织方式 main()中正常处理的代码被放在函数体中 与 catch 子句中处理异常 的代码清楚地分开 但是 在 main()的函数体中声明的变量不能在 catch 子句中被引用 一个函数 try 块把一组 catch 子句同一个函数体相关联 如果函数体中的语句抛出一个异 常 则考虑用跟在函数体后面的处理代码来处理该异常 函数 try 块对类构造函数尤其有用 我们将在第 19 章重新回顾这种上下文环境中的函数加块 练习 11.3 请编写一个程序 使它定义一个 IntArray 对象 这里 IntArray 是在 2.3 节中定义的 class 类型 并执行下列动作 我们有三个含有整型值的文件 1 读取第一个文件 把读入的第 1 3 5 n 个 这里 n 为奇数 数值赋给 IntArray 对象 然后显示 IntArray 的内容 2 读取第二个文件 并把读入的第 5 10 n 个 这里 n 为 5 的倍数 数值赋给 IntArray 对象 然后显示 IntArray 的内容 3 读取第三个文件 把读入的第 2 4 6 n 个 这里 n 为偶数 数值赋给 IntArray 对象 然后显示 IntArray 的内容 用练习 11.2 定义的 IntArray operator[]()向 IntArray 对象读写值 因为 operator[]()可能抛 出异常 所以要在的程序中用一个或多个 try 块和 catch 子句以处理 operator[]()可能抛出的异 常 说明你在程序中放置 try 块的位置的原因 11.3 捕获异常 C++异常处理代码是 catch 子句 catch clause 当一个异常被 try 块中的语句抛出时 系统通过查看跟在 try 块后面的 catch 子句列表 来查找能够处理该异常的 catch 子句 一个 catch 子句由三部分构成 关键字 catch 在括号中的单个类型或单个对象声明 被 称作异常声明 exception declaration 以及复合语句中的一组语句 如果选择了一个 catch 子 句来处理一个异常 则执行相应的复合语句 让我们详细地看看 main()函数中的 pushOnFull 和 popOnEmpty 异常的 catch 子句 catch ( pushOnFull ) { cerr << "trying to push a value on a full stack\n"; return errorCode88; 456 第十一章 异常处理 } catch ( popOnEmpty ) { cerr << "trying to pop a value on an empty stack\n"; return errorCode89; } 两个 catch 子句都有一个 class 类型的异常声明 第一个是 pushOnFull 类型 第二个是 popOnEmpty 类型 如果异常声明的类型与被抛出的异常类型匹配 则选择这段处理代码来 处理异常 我们将在第 19 章看到类型不必完全匹配 基类的处理代码可以处理从异常声明 类型派生出来的 class 类型的异常 例如 当 iStack 的成员函数 pop()抛出一个类型为 popOnEmpty 的异常时 则进入第二个 catch 子句 在发出一个错误消息给 cerr 之后 函数 main()返回 errorCode89 如果这些 catch 子句不包含返回语句 那么程序的执行将继续到哪儿呢 在 catch 子句完 成它的工作之后 程序的执行将在 catch 子句列表的最后子句之后继续进行 在我们的例子 中 程序的执行在 main()的返回语句处继续 在 popOnEmpty 的 catch 子句向 cerr 产生一个 错误消息之后 main()返回 0 int main() { iStack stack( 32 ); try { stack.display(); for ( int ix = 1; ix < 51; ++ix ) { // 同前 } } catch ( pushOnFull ) { cerr << "trying to push a value on a full stack\n"; } catch ( popOnEmpty ) { cerr << "trying to pop a value on an empty stack\n"; } // 程序在这里继续 return 0; } C++的异常处理机制被称为是不可恢复的 nonresumptive 一旦异常被处理 程序的 执行就不能够在异常被抛出的地方继续 在我们的例子中 一旦异常被处理 程序的执行就 不能够在 pop()成员函数中异常被抛出的地方继续 11.3.1 异常对象 catch 子句的异常声明可以是一个类型声明或一个对象声明 什么时候 catch 子句中的异 常声明应该声明一个对象 当我们要获得 throw 表达式的值 或者要操纵 throw 表达式所创 建的异常对象时 我们应该声明一个对象 假设我们设计自己的异常类 当该异常被抛出时 我们把信息存储在异常对象中 如果 catch 子句的异常声明声明了一个对象 则 catch 子句中 的语句就可以用该对象来引用由 throw 表达式存储的信息 457 第十一章 异常处理 例如 我们改变 pushOnFull 异常类的设计 我们在该异常对象中保存不能被压入到栈中 的值 修改 catch 子句 向 cerr 发出错误信息时显示这个值 为了实现它 我们首先需要改 变 pushOnFull 类的定义 下面是我们的新定义 // 新异常类: // 负责保存不能被压入到栈中的值 class pushOnFull { public: pushOnFull( int i ) : _value( i ) { } int value() { return _value; } private: int _value; }; 新的私有数据成员_value 拥有不能被压入栈中的那个值 构造函数取一个 int 型的值 把这个值存储在_value 数据成员中 下面是构造函数怎样被 throw 表达式调用 以便把不能 压入到栈中的那个值存储在异常对象中 void iStack::push( int value ) { if ( full() ) // 把 value 存储在异常对象中 throw pushOnFull( value ); // ... } 类 pushOnFull 也有一个新的成员函数 value() 它可以被用在 catch 子句中 以便显示异 常对象中的值 下面是它的用法 catch ( pushOnFull eObj ) { cerr << "trying to push the value " << eObj.value() << " on a full stack\n"; } 注意 catch 子句的异常声明声明了对象 eObj 用它来调用 pushOnFull 类的成员函数 value() 异常对象总是在抛出点被创建 即使 throw 表达式不是一个构造函数调用 或者它没有 表现出要创建一个异常对象 情况也是如此 例如 enum EHstate { noErr, zeroOp, negativeOp, severeError }; enum EHstate state = noErr; int mathFunc( int i ) { if ( i == 0 ) { state = zeroOp; throw state; // 创建异常对象 } // 否则, 正常处理流程继续 } 在这个例子中 对象 state 没有被用作异常对象 而是由 throw 表达式创建了一个类型为 EHstate 的异常对象 并且用全局对象 state 的值初始化该对象 程序是怎样分辨出该异常对 458 第十一章 异常处理 象不同于全局对象 state 呢 为了回答这个问题 我们必须先仔细地看看 catch 子句的异常声 明 catch 子句异常声明的行为特别像参数声明 当进入 catch 子句时 如果异常声明声明了 一个对象 则用该异常对象的拷贝初始化这个对象 例如 下面的函数 calculate()调用前面 定义的函数 mathFunc() 当进入 calculate()中的 catch 子句时 对象 eObj 由 throw 表达式创建 的异常对象的拷贝进行初始化 void calculate( int op ) { try { mathFunc( op ); } catch ( EHstate eObj ) { // eObj 是被抛出的异常对象的拷贝 } } 这个例子中的异常声明类似于按值传递的参数 对象 eObj 用该异常对象的值初始化 就 好像一个按值传递的函数参数用相应实参的值初始化一样 关于按值传递的参数的讨论见 7.3 节 与函数参数的情形一样 catch 子句中的异常声明也可以被改变成引用声明 于是 catch 子句就可以直接引用由 throw 表达式创建的异常对象 而不是创建一个局部拷贝了 例如 void calculate( int op ) { try { mathFunc( op ); } catch ( EHstate &eObj ) { // eObj 引用了被抛出的异常对象 } } 为了防止不必要地拷贝大型类对象 class 类型的参数应该被声明为引用 同样原因 如 果 class 类型异常的异常声明被声明为引用 也是比较好的 使用引用类型的异常声明 catch 子句能够修改异常对象 但是 由 throw 表达式指定的 任何变量仍都不受影响 例如 在 catch 子句中修改 eObj 对象 不会影响由 throw 表达式指 定的全局变量 state void calculate( int op ) { try { mathFunc( op ); } catch ( EHstate &eObj ) { // 修正异常情况 eObj = noErr; // 全局变量 state 没有被修改 } } 在修正异常情况之后 这个 catch 子句将对 eObj 重置为 noErr 因为 eObj 是个引用 所以 我们可以期望该赋值修改全局变量 state 但是 该赋值只修改由 throw 表达式创建的异常对 象 因为异常对象与全局变量 state 不同 所以修改了 catch 子句中的 eObj 后 state 仍保其 459 第十一章 异常处理 个变 11.3.2 栈展开 找到一个 catch 子句 以处理被抛出的异常的过程如下 如果 throw 表达式位于 try 块中 则检查与 try 块相关联的 catch 子句 看是否有一个子句能够处理该异常 如果找到一个 catch 子句 则该异常被处理 如果没有找到 catch 子句 则在主调函数中继续查找 如果一个函 数调用在退出时带着一个被抛出的异常 并且这个调用位于一个 try 块中 则检查与该 try 块 相关联的 catch 子句 看是否有一个子句能够处理该异常 如果找到了一个 catch 子句 则该 异常被处理 如果没有找到 catch 子句 则查找过程在主调函数中继续 这个过程沿着嵌套 函数调用链向上继续 直到找到该异常的 catch 子句 只要一遇到能够处理该异常的 catch 子 句 就会进入该 catch 子句 程序的执行在该处理代码中继续 在我们的例子中 查找 catch 子句的第一个函数是 iStack 类的成员函数 pop() 因为 pop() 中的 throw 表达式没有在 try 块中 所以 pop()带着一个异常而退出 要检查的下一个函数是 调用成员函数 pop()的函数 在我们的例子中 它是 main() 在 main()中的 pop()调用位于一 个 try 块中 系统考虑由与该 try 块关联的 catch 子句来处理该异常 一个 popOnEmpty 类型 的异常的 catch 子句被找到 并进入它以处理该异常 在查找用来处理被抛出异常的 catch 子句时 因为异常而退出复合语句和函数定义 这 个过程被称作栈展开 stack unwinding 随着栈的展开 在退出的复合语句和函数定义中 声明的局部变量的生命期也结束了 C++保证 随着栈的展开 尽管局部类对象的生命期是 因为抛出异常而被结束 但是这些局部类对象的析构函数也会被调用 我们将在第 19 章更详 细地介绍这些内容 如果一个程序没有为已被抛出的异常提供 catch 子句 该怎么办呢 异常不能够保持在 未被处理的状态 异常对于一个程序非常重要 它表示程序不能够继续正常执行 如果没有 找到处理代码 程序就调用 C++标准库中定义的函数 terminate() terminate()的缺省行为是调 用 abort() 指示从程序非正常退出 在大多数情况下 调用 abort()已经足够了 但是在某 些特殊情况下 我们有必要改变由 terminate()执行的动作 STROUSTRUP97 给出了怎样做 到这一点 并作了详细的讨论 到目前为止 你可能已经注意到在异常处理和函数调用之间的许多相似之处 throw 表 达式的行为有点像函数调用 而 catch 子句有点像函数定义 这两种机制的一个主要区别是 建立函数调用所需要的全部信息在编译时刻已经获得 而对异常处理机制则不然 C++异常 处理要求运行时刻的支持 例如 对于一个普通函数调用 通过函数重载解析过程 编译器 知道在调用点上哪个函数会被真正调用 但是对于异常处理 编译器不知道特定的 throw 表 达式的 catch 子句在哪个函数中 以及在处理异常之后执行权被转交到哪儿 这些决策必须 在运行时刻进行 当一个异常不存在处理代码时 编译器无法通知用户 这就是为什么 terminate()函数存在的原因 它是一种运行时刻机制 当没有处理代码能够匹配被抛出的异 常时由它通知用户 11.3.3 重新抛出 在异常处理过程中也可能存在 单个 catch 子句不能完全处理异常 的情况 在某些修 460 第十一章 异常处理 正动作之后 catch 子句可能决定该异常必须由函数调用链中更上级的函数来处理 那么 catch 子句可以通过重新抛出 rethrow 该异常 把异常传递给函数调用链中更上级的另一个 catch 子句 rethrow 表达式的形式为 throw; rethrow表达式重新抛出该异常对象 rethrow 只能出现在 catch 子句的复合语句中 例 如 catch ( exception eObj ) { if ( canHandle( eObj ) ) // 处理异常 return; else // 重新抛出它, 并由另一个 catch 子句来处理 throw; } 被重新抛出的异常就是原来的异常对象 如果 catch 子句在重新抛出异常对象之前对它 作了修改 那么这会有某些隐含的意义 下列代码没有修改原来的异常对象 你能看出为什 么吗 enum EHstate { noErr, zeroOp, negativeOp, severeError }; void calculate( int op ) { try { // 被 mathFunc() 抛出的异常的值为 zeroOp mathFunc( op ); } catch ( EHstate eObj ) { // 做某些修正 // 试图修改异常对象 eObj = severeErr; // 希望重新抛出值为 severeErr 的异常对象 throw; } } 因为 eObj 不是引用 所以 catch 子句接收到的是异常对象的拷贝 在处理代码中对 eObj 所做的任何修改都只是改变了局部拷贝 它们不影响由 throw 表达式创建的原来的异常对象 因为在我们的例子中 catch 子句里没有修改原来的异常 所以被重新抛出的对象仍然具有初 始的 zeroOp 值 为了修改原来的异常对象 catch 子句中的异常声明必须被声明为引用 例如 cacth ( EHstate &eObj ) { // 修改异常对象 eObj = severeErr; // 被重新抛出的异常的值是 severeErr throw; } 461 第十一章 异常处理 eObj指向由 throw 表达式创建的异常对象 在 catch 子句中对 eObj 的修改影响了原来的 异常对象 这些修改是被重新抛出的异常对象的一部分 所以 把 catch 子句的异常声明 声明为引用的另一个原因是 确保应用在 catch 子句 中的异常对象上的修改操作 能够反映到被重新抛出的异常对象上 我们将在 19.2 节了解 为 什么 class 类型异常的异常声明应该是一个引用 的另外一个原因 在那里我们将了解 catch 子句怎样调用类的虚拟函数 11.3.4 catch-all 处理代码 即使一个函数不能处理被抛出的异常 但是它也可能希望在带着异常退出之前执行一些 动作 例如 函数可能获得了一些资源 如打开一个文件或在堆中分配了一些内存 它可能 想在随着异常退出之前释放这些资源 关闭文件或释放内存 例如 void mainip() { resource res; res.lock(); // 锁定资源 // 使用 res // 可能引起异常抛出的动作 res.release(); // 如果抛出异常则跳过 } 如果有一个异常被抛出 则资源 res 的释放被跳过去 为保证该资源被释放 我们不是 为每种可能的异常都写一个 catch 子句 因为我们不知道可能被抛出的全部异常 但是我们 可以使用 catch 子句 catch-all 这种 catch 子句有一个形式为 ... 的异常声明 这里的二个点 被称为省略号 ellipsis 对任何类型的异常 都会进入这个 catch 子句 例如 // 对任何异常都会进人 catch ( ... ) { // 这里是我们的代码 } catch(...)和 throw 表达式被组合起来使用 对于已经被锁定的资源 在异常被一个 rethrow 表达式传递给函数调用链中更上级的函数之前 它们在 catch 子句的复合语句中被释放 void manip() { resource res; res.lock(); try { // 使用 res // 某些能够引起异常被抛出的动作 } catch ( ... ) { res.release(); throw; } res.release(); // 如果抛出异常则跳过 } 为了确保该资源被正确地释放 如果一个异常被抛出并且 manip()随着异常而退出 则在 462 第十一章 异常处理 异常被传递给函数调用链的上层函数之前 我们可以用一个 catch(...)来释放该资源 我们也 可以通过把资源封装在一个类中 以便管理资源的请求和释放 类的构造函数获得资源 而 类的析构函数自动释放资源 我们将在第 19 章了解怎样实现这种策略 catch(...)可以自己单独使用 也可以与其他 catch 子句联合使用 如果它与其他 catch 子 句联合使用 那么在组织与 try 块相关的一组 catch 子句时我们必须小心 catch 子句被检查的顺序与它们在 try 块之后出现的顺序相同 一旦找到了一个匹配 则 后续的 catch 子句将不再检查 这意味着 如果 catch(...)与其他 catch 子句联合使用 它必须 总是被放在异常处理代码表的最后 否则就会产生一个编译时刻错误 例如 try { stack.display(); for (int ix = 1; ix < 51; ++ix ) { // 与前面相同 } } catch ( pushOnFull ) {} catch ( popOnEmpty ) { } catch (...) { } // 必须是最后一个 catch 子句 练习 11.4 请说明为什么说 C++的异常处理机制是不可恢复的 练习 11.5 已知下列异常声明 请给出一个 throw 表达式 它可以创建一个能够被下列 catch 子句捕 获的异常对象 (a) class exceptionType { }; catch( exceptionType *pet ) { } (b) catch( ... ) { } (c) enum mathErr { overflow, underflow, zeroDivide } catch( mathErr &ref ) { } (d) typedef int EXCPTYPE; catch( EXCPTYPE ) { } 练习 11.6 说明在栈展开过程中发生的事情 练习 11.7 请给出 catch 子句的异常声明应该被声明为引用的两个原因 练习 11.8 请用练习 11.3 开发的代码 修改你创建的异常类 以便将 operator[]()的非法索引存储在 异常对象中 当该异常被抛出时 能够在后面用 catch 子句显示它 修改你的程序使得 463 第十一章 异常处理 operator[]()在程序执行期间抛出一个异常 11.4 异常规范 通过查看 iStack 类的成员函数 pop()和 push()的声明 来判断这些函数可能会抛出异常 这是不可能的 一种可能的方案是 在每个成员函数的声明处附加上相关的注释 通过这种 方式 出现在头文件中的类接口也给类成员函数可能抛出的异常做了文档 class iStack { public: // ... void pop( int &value ); // 抛出 popOnEmpty void push( int value ); // 抛出 pushOnFull private: // ... }; 但这还是不太理想 因为无法保证该文档会随着 istack 类以后的发行而自动更新 它没 有向编译器提供信息保证不会抛出其他种类的异常 异常规范 exception specification 提供 了一种方案 它能够随着函数声明列出该函数可能抛出的异常 它保证该函数不会抛出任何 其他类型的异常 异常规范跟随在函数参数表之后 它用关键字 throw 来指定 后面是用括号括起来的异 常类型表 例如 我们可以如下修改 iStack 类的成员函数的声明 以增加适当的异常规范 class iStack { public: // ... void pop( int &value ) throw(popOnEmpty); void push( int value ) throw(pushOnFull); private: // ... }; 对于 pop()的调用 保证不会抛出任何 popOnEmpty 类型之外的异常 类似地 对于 push() 的调用 保证不会抛出任何 pushOnFull 类型之外的异常 异常声明是函数接口的一部分 它必须在头文件中的函数声明上指定 异常规范是函数 和程序余下部分之间的协议 它保证该函数不会抛出任何没有出现在其异常规范中的异常 如果函数声明指定了一个异常规范 则同一函数的重复声明必须指定同一类型的异常规 范 同一函数的不同声明上的异常规范是不能累积的 例如 // 同一函数的两个声明 extern int foo( int = 0 ) throw(string); // 错误: 异常规范被省略 extern int foo( int parm ) { } 464 第十一章 异常处理 如果函数抛出了一个没有被列在异常规范中的异常会怎么样 程序只有在遇到某种不正 常情况时 异常才会被抛出 在编译时刻编译器不可能知道 在执行时程序是否会遇到这些 异常 因此 一个函数的异常规范的违例只能在运行时刻才能被检测出来 如果函数抛出了 一个没有被列在其异常规范中的异常 则系统调用 C++标准库中定义的函数 unexpected() unexpected()的缺省行为是调用 terminate() 在某些条件下 可能有必要改变 unexpected() 执行的动作 C++标准库提供了一种机制 可让我们改变 unexpected()的缺省行为 STROUSTRUP97 更详细地讨论了这些 我们应该澄清一下 如果函数抛出了一个没有被列在其异常规范中的异常 系统未必就 会调用 unexpected() 如果该函数自己处理该异常 并且该异常在 逃离 该函数之前被处 理掉 那么一切都不会有问题 例如 void recoup( int op1, int op2 ) throw(ExceptionType) { try { // ... throw string("we're in control"); } // 处理抛出的异常 catch ( string ) { // 做一些必要的工作 } } // ok, unexpected()没有被调用 即便在函数 recoup()中抛出 string 类型的异常 而且函数 recoup()保证不会抛出 ExceptionType 类型之外的其他异常 但是因为该异常在其 逃离 函数 recoup()之前被处理 了 所以系统不会由于该函数抛出 string 类型的异常而调用函数 unexpected() 函数异常规范的违例只有在运行时刻才能被检测到 如果一个表达式能够抛出一个不被 规范允许的异常类型 则编译器不会产生编译时刻错误 如果这个表达式不会被执行 或者 它从没有抛出违反异常规范的那个异常 则该程序会像期望的那样运行 而且该函数异常规 范从不会被违反 例如 extern void doit( int, int ) throw(string, exceptionType); void action ( int op1, int op2) throw(string) { doit( op1, op2 ); // 没有编译错误 // ... } 函数 doit()可以抛出一个 exceptionType 类型的异常 它不是函数 action()的异常规范所 允许的 即使函数 action()不允许这种类型的异常 该函数也能编译成功 编译器产生相应的 代码以确保当违反异常规范的异常被抛出时 调用运行库函数 unexpected() 空的异常规范保证函数不会抛出任何异常 例如 函数 no_problem()保证不会抛出任何 异常 extern void no_problem() throw(); 如果一个函数声明没有指定异常规范 则该函数可以抛出任何类型的异常 在被抛出的异常类型与异常规范中指定的类型之间不允许类型转换 例如 465 第十一章 异常处理 int convert( int parm ) throw(string) { // ... if ( somethingRather ) // 程序错误: // convert() 不允许 const char* 型的异常 throw "help!"; } 在函数 convert()中的 throw 表达式抛出一个 C 风格的字符串 由这个 throw 表达式创建 的异常对象的类型为 const char* 通常 const char*型的表达式可以被转换成 string 类型 但是 异常规范不允许从被抛出的异常类型到异常规范指定的类型之问的转换 如果 convert() 抛出该异常 则调用函数 unexpected() 为了修正这种情况 可以如下修改 throw 表达式 显式地把表达式的值转换成 string 类型 throw string( "help!" ); 11.4.1 异常规范与函数指针 我们也可以在函数指针的声明处给出一个异常规范 例如 void (*pf) (int) throw(string); 该声明表示 pf 是一个函数指针 它只能抛出 string 类型的异常 和函数声明一样 同一 指针的不同异常规范不能累积 指针 pf 的所有声明都必须指定相同的规范 例如 extern void (*pf)( int ) throw(string); // 错误: 缺少异常规范 void (*pf) ( int ); 当带有异常规范的函数指针被初始化 或被赋值 时 对于用作初始值 或用作赋值右 边的右值 的指针类型有一些限制 这两个指针的异常规范不必完全一样 但是 用作初始 值或右值的指针异常规范必须与被初始化或赋值的指针异常规范一样或更严格 例如 void recoup( int, int ) throw(exceptionType); void no_problem() throw(); void doit( int, int ) throw(string, exceptionType); // ok: recoup() 与 pf1 的异常规范一样严格 void (*pf1)( int, int ) throw(exceptionType) = &recoup; // ok: no_problem() 比 pf2 更严格 void (*pf2)() throw(string) = &no_problem; // 错误: doit()没有 pf3 严格 void (*pf3)( int, int ) throw(string) = &doit; 第三个初始化没有意义 该指针的声明保证 pf3 指向一个函数 该函数不会抛出除了 string 类型之外的任何异常 但是函数 doit()可能抛出一个 exceptionType 类型的异常 因为 函数 doit()不能满足 pf3 的异常规范的保证 所以 函数 doit()不是 pf3 的合法初始值 因而 会产生一个编译错误 466 第十一章 异常处理 练习 11.9 请用练习 11.8 开发的代码 修改类 IntArray 的 operator[]()声明 加入适当的异常规范来 描述这个操作符可能抛出的异常 修改你的程序 使 operator[]()抛出一个没有被列在异常规 范中的异常 那么 会怎么样 练习 11.10 如果函数有一个形式为 throw()的异常规范 那么它可以抛出什么异常 如果没有异常规 范呢 练习 11.11 下列哪些指针赋值是错误的 为什么 void example() throw(string); (a) void (*pf1)() = example; (b) void (*pf2)() throw() = example; 11.5 异常与设计事项 在 C++程序设计中 有一些和异常处理的用法相关的设计事项 虽然对于异常处理的支 持是被内置在语言中的 但并不是每个 C++程序都应该使用异常处理 因为抛出异常不像正 常函数调用那样快 所以异常处理应该用在独立开发的不同程序部分之间 用于不正常情况 的通信 例如 一个库的实现者可能决定用异常向库用户通知程序的异常情况 如果库函数 遇到一种不能局部处理的意外情况 它可能会抛出一个异常通知使用该库的程序 在我们的例子中 的库定义了 iStack 类及其成员函数 函数 main()使用这个库 我们应 该假设写 main()的程序员不是库的实现者 类 iStack 的成员函数能够检测到在一个空栈上的 pop()操作请求 或在一个满的栈上的 push()操作请求 但是库的实现者不知道引起 pop()或 push()操作请求的程序状态 所以不能够在编写 pop()和 push()成员函数时 在局部函数内解 决这种情况 因为这些错误不能在成员函数中被处理 所以我们决定抛出异常 以便通知使 用该库的程序 即使 C++支持异常处理 C++程序仍然应该使用其他的错误处理技术 比如在适当时返 回一个错误代码 对 错误何时会变成异常 这个问题 没有明确的答案 确定什么是一种 意外的情况 这实际上是库的实现者的责任 异常是库接口的一部分 决定一个库抛出 哪些异常是库设计的一个重要阶段 如果该库希望被用在不会崩溃的程序中 那么该库必须 自己处理问题 或者如果它不能处理的话 则必须把程序的不正常情况通知给使用该库的程 序部分 在库代码本身没有可采取的有意义动作时 让调用者选择应该采取什么行动 决定 把哪些情况应该当作异常来处理 是库设计中很难的一部分 在我们的 iStack 的例子中 成员函数 push()在栈满时是否应该抛出一个异常是有争议的 有些人可能会说这样更好 push()的实现可以局部地处理这种情况 在栈满时仍增长栈 毕 467 第十一章 异常处理 竟 惟一真正的限制是我们程序的可用内存 在栈满时压入一个值就抛出异常 的决定可 能是一个错误的考虑 我们则以重新实现成员函数 push() 在向一个满栈请求压入一个值时 继续增长栈 void iStack::push( int value) { // 如果满, 增长底层的 vector if ( full() ) _stack.resize( 2 * _stack.size() ); _stack[ _top++ ] = value; } 类似地 当要求从空栈中抛出一个值时 pop()是否应该抛出异常 一个有趣的观察是 C++标准库的 stack 类 在第 6 章介绍 在要求一个弹出动作 而栈为空时 并没有抛出异常 而是这个操作有一个未定义行为 它不知道在要求这样的操作之后程序的行为是什么 在设 计 C++标准库时 显然已经确定在这种情况下不应该抛出异常 在遇到非法状态时 允许 程序继续进行 在这种情况下被认为是合适的 正如我们所提到的 不同的库会有不同的异 常 对于 什么构成了一个异常 的问题还没有正确的答案 不是所有的程序都应该担心库会抛出异常 尽管有些系统不能忍受宕机的风险 因而应 该处理异常事件情况 但不是每个系统都有这样的要求 异常处理是容错系统实现中的主要 辅助手段 决定一个程序是否处理由库抛出的异常 或是让程序终止运行 是设计过程中很 难的一部分 程序设计中有关异常处理的最后一方面是 程序中的异常处理通常是分层的 一个程序 通常是由一些组件构成 每个组件必须决定它将处理哪些异常 应该将哪些异常传递给程序 的上一层 我们的组件指的是什么 例如 第 6 章介绍的文本查询系统可以分成三个组件或 层 第一层是 C++标准库 它提供对 string map 等等的基本操作的支持 第二层是文本查 询系统本身 它定义了函数 如 string caps()和 suffix_text() 它们操纵要被处理的文本并把 C++标准库用作于组件 第三层是使用文本查询系统的程序 每个组件或层被独立生成 并 且都必须决定哪些异常情况它会本地处理 哪些异常会传递给程序的高层 在一个层或组件中 不是每个函数都应该能够处理异常 通常 try 块和 catch 子句被一 个程序组件的入口点函数使用 catch 子句处理当前组件中不适合传递给高层程序的异常 异 常规范 11.4 节讨论 也用于一个组件的入口函数中 确保不希望传递给上一层程序的异常 不会 逃离 我们将在第 19 章介绍了类和类层次结构之后 了解有关异常的程序设计的其他方面 12 泛型算法 在第 2 章的 Array 类的实现中 我们提供了支持 min() max() find()和 sort()的成 员操作 但是 标准 vector 类并没有提供这些显然很基本的操作 为了在 vector 的元素中找到最小或最大值 我们必须调用一个泛型算法 generic algorithm 算法 是因为它们实现公共的操作 如 min() max() find()和 sort() 泛型 是因为它们操作在多种容器类型上——例如 不但有 vector 和 list 类型 还有内置 数组类型 容器通过一对 iterator 迭代器 我们在 6.5 节简要讨论了 iterator 被 绑定到某个泛型算法上 这对 iterator 标记了要遍历的元素范围 特殊的函数对象 function object 允许我们改变泛型算法的缺省操作语义 泛型算法 函数对象 以及 iterator 的详细介绍形成了本章的主题 12.1 概述 每个泛型算法的实现都独立于单独的容器类型 因为已经消除了算法的类型依赖性 所 以单个的模板实例可以操作在各种容器以及内置数组类型上 考虑 find()算法 因为它独立 于被适用的容器 所以它只要求下列一般性的步骤 这里假设资料集合未经排序 1 顺次检查每个元素 2 如果当前元素等于要被查找的值 那么返回该元素在集合中的位置 3 否则 检查下一个元素 重复步骤 2 直到找到一个元素 或者检查完所有元素 4 如果已经到了集合的末尾 而且还没有找到该值 则返回某个值指明该值在这个集合 中不存在 这个算法 正如我们所指出的 与被应用的容器类型以及被查找的值的类型无关 算法 的要求如下 1 我们需要某种遍历集合的方式 这包括 向前移到下一个元素 以及识别下一元素是 否是末元素 的概念 典型情况下 对了内置数组类型 除了 C 风格字符串以外 我们传 递两个实参来解决这个问题 首元素的指针 以及要遍历的元素的个数 对于 C 风格字符串 元素个数是不必要的 字符串的末尾由一个终止空字符来指示 469 第十二章 泛型算法 2 我们需要能够对容器中的元素与被查找元素进行比较 典型情况下 这可以通过使用 与其关联的底层类型的 等于 操作符 或者传递一个执行该操作的函数的指针来解决 3 我们需要一个公共类型来表示元素在容器中的位置 以及如果没有找到时使用的无位 置 no position 典型情况下 我们返回元素的索引 -1 或者指向该元素的指针或 0 泛型算法用 iterator 抽象来解决第一个问题 对容器的遍历 iterator 提供了对指针的一 个泛化 它至少支持下列操作符 递增操作符以用来访问下一个元素 解引用操作符用来访 问实际的元素 以及等于和不等于操作符用来判定两个 iterator 是否相等 算法遍历的元素范 围由一对 iterator 标记 一个 first iterator 指问要操作的首元素 和一个 last iterator 标记要操 作的末元素的下一位置 由 last 指向的元素 不是要操作的元素 它被用作终止遍历的哨兵 sentinel 同时也被用作指示没有找到的返回值 如果找到了该值 则返回标记该位置的 iterator 泛型算法解决第二个要求 值的比较 所用的方法是 为每个算法提供两个版本 一个 使用元素底层类型的等于操作符 另一个使用函数对象或函数指针来实现比较 关于函数对 象将在 12.3 节解释 例如 下面是 find()的泛化实现 它使用了底层类型的等于操作符 template < class ForwardIterator, class Type > ForwardIterator find( ForwardIterator first, ForwardIterator last, Type value ) { for ( ; first != last; ++first ) if ( value == *first ) return first; return last; } ForwardIterator是标准库预定义的五种 iterator 之一 ForwardIterator 支持读写它所指向 的元素 这五种 iterator 将在 12.4 节给出 由于这个算法不直接访问容器的元素 因而获得了类型独立性 元素的全部访问和遍历 都通过 iterator 实现 实际的容器类型 可能是一个容器类型 也可能是内置数组 未知 为 支持内置数组类型 普通指针以及 iterator 都可以被传递给泛型算法 例如 下面的例子用 int 型的内置数组来使用 find() #include #include int main() { int search_value; int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 }; cout << "enter search value: "; cin >> search_value; int *presult = find( &ia[0], &ia[6], search_value ); cout << "The value " << search_value << ( presult == &ia[6] ? " is not present" : " is present" ) 470 第十二章 泛型算法 << endl; } 如果返回的指针等于 ia[6]的地址 即 ia 末元素的下一位置 则查找失败 否则 相应 的值就被找到 通常 向泛型算法传递指针时 我们可以写成 int *presult = find( &ia[0], &ia[6], search_value ); 或不太明确地写成 int *presult = find( ia, ia+6, search_value ); 如果希望传递一个子范围 我们只需修改传递给算法的地址索引 例如 在 find()的调 用中 只查找第二个和第三个元素 记住元素从 0 开始计数 // 只查找元素 ia[1] 和 ia[2] int *presult = find( &ia[1], &ia[3], search_value ); 下面的例子用 vector 容器类型使用 find() #include #include #include int main() { int search_value; int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 }; vector vec( ia, ia+6 ); cout << "enter search value: "; cin >> search_value; vector::iterator presult; presult = find( vec.begin(), vec.end(), search_value ); cout << "The value " << search_value << ( presult == vec.end() ? " is not present" : " is present" ) << endl; } 类似地 下面是对 list 容器类型的 find()用法 #include #include #include int main() { int search_value; int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 }; list ilist( ia, ia+6 ); cout << "enter search value: "; cin >> search_value; 471 第十二章 泛型算法 list::iterator presult; presult = find( ilist.begin(), ilist.end(), search_value ); cout << "The value " << search_value << ( presult == ilist.end() ? " is not present" : " is present" ) << endl; } 在下一节中 我们将了解一个设计 它是一个用到了各种泛型算法的程序 在其后的小 节中 我们将介绍函数对象 12.4 节将介绍关于 iterator 的更多细节 在 12.5 节中我们将简 要地介绍泛型算法——每个算法的说明以及详细讨论被放到附录中 在本章最后 我们将讨 论何时不宜使用泛型算法 练习 12.1 对泛型算法的批评是 它的设计虽然很优雅 但却把正确性的责任放在程序员身上 例 如 无效的 iterator 或标记了一个无效范围的 iterator 对 会导致未定义的运行时刻行为 这 个批评对吗 对这些算法的使用应该只局限于很有经验的程序员吗 一般来说 程序员应该 接受保护 以避免诸如泛型算法 指针和显式强制转换这样的语言结构中存在潜在的错误 是这样吗 12.2 使用泛型算法 考虑如下的程序设计任务 我们想写一本儿童用书 希望知道适用于这本书的词汇层次 我们的想法如下 我们将阅读一定数量的儿童书籍 把其中的文本存储在 string vector 中 我 们已经知道该怎样做——见 6.7 节 下面是我们要做的 1 拷贝每个 vector 2 把 5 个 vector 合并成一个大的 vector 3 以字母顺序排列大的 vector 4 去掉所有重复的单词 5 再按单词的长度排序 6 计数超过 6 个字符的词的个数 长度是一个测量复杂度的依据 至少对词汇是这样 7 去掉任何没有语义的中性词 如 and if or but 等等 8 打印 vector 这听起来像是要一章才能完成的工作 但是 使用泛型算法 我们可以把它缩短到本章 的一个很短的小节中 我们的函数的实参是一个 string vector 的 vector 我们以指针方式接受它 首先测试它是 否非空 #include #include typedef vector textwords; 472 第十二章 泛型算法 void process_vocab( vector*pvec ) { if ( ! pvec ) { // 给出警告信息 return; } // ... } 我们希望做的第一件事情是 创建一个 vector 它包含各个 vector 中的元素 我们可以 用如下的 copy()泛型算法做到这一点 需要包含 algorithm 和 iterator 头文件 #include #include void process_vocab( vector< textwords >*pvec ) { // ... vector< string > texts; vector::iterator iter = pvec->begin(); for ( ; iter != pvec->end(); ++iter ) copy( (*iter).begin(), (*iter).end(), back_inserter( texts )); // ... } copy()算法把一对 iterator 当作前两个实参 用它们标记出要拷贝的元素范围 第三个实 参是一个 iterator 它标记了被拷贝元素将被放置的起始位置 back_inserter 被称为 iterator 适配器 它使得元素被插入到作为实参的 vector 的尾部 我们将在 12.4 节详细查看 iterator 适配器 unique()虽然去掉了容器中的重复值 但是只去掉相邻的重复值 即 序列 01123211 的 结果是 012321 而不是 0123 为了得到后一种结果 必须先对 vector 进行 sort() 即把序列 01111223 变成 0123 好 差不多了 实际上 结果是 01231223 unique()的行为有些不符合直觉 它操作的容器的长度没有被改变 每个独一无二的元素 被放到从头开始的下一个自由槽中 在我们的例子中 实际的结果是 01231223 序列 1223 表示的是算法的废弃部分 refuse unique()返回一个 iterator 指向这个废弃部分的开始处 典型情况下 这个 iterator 被传递给相关的容器操作 erase()来删除无效的元素 因为内置数 组不支持 erase()操作 所以 unique()算法族不太适合于内置数组类型 下面是函数的一部 分 void process_vocab( vector< textwords >*pvec ) { // ... // 排序 texts 的元素 sort( texts.begin(), texts.end() ); // 删除重复的元素 473 第十二章 泛型算法 vector::iterator it; it = unique( texts.begin(), texts.end() ); texts.erase( it, texts.end() ); // ... } 下面是在 sort()之后但还没有调用 unique()之前 合并了两个小文本文件的 texts 的输出 例子 a a a a alice alive almost alternately ancient and and and and and and and as asks at at beautiful becomes bird bird blows blue bounded but by calling coat daddy daddy daddy dark darkened darkening distant each either emma eternity falls fear fiery fiery flight flowing for grow hair hair has he heaven, held her her her her him him home houses i immeasurable immensity in in in in inexpressibly is is is it it it its journeying lands leave leave life like long looks magical mean more night, no not not not now now of of on one one one passion puts quite red rises row same says she she shush shyly sight sky so so star star still stone such tell tells tells that that the the the the the the the there there thing through time to to to to trees unravel untamed wanting watch what when wind with with you you you you your your 在应用了 unique() 并调用了 erase()之后 texts vector 看起来是这样的 a alice alive almost alternately ancient and as asks at beautiful becomes bird blows blue bounded but by calling coat daddy dark darkened darkening distant each either emma eternity falls fear fiery flight flowing for grow hair has he heaven, held her him home houses i immeasurable immensity in inexpressibly is it its journeying lands leave life like long looks magical mean more night, no not now of on one passion puts quite red rises row same says she shush shyly sight sky so star still stone such tell tells that the there thing through time to trees unravel untamed wanting watch what when wind with you your 我们的下一个任务是按长度排序字符串 为实现它 我们不用 sort()而是用 stable_sort() 算法 stable_sort()保留相等元素的相对位置 也就是说 对于长度相同的元素 当前的字母 顺序被保留 为实现按长度排序 我们给出自己的小于等于操作 下面是一种实现方式 bool less_than( const string & s1, const string & s2 ) { return s1.size() < s2.size(); 474 第十二章 泛型算法 } void process_vocab( vector< textwords >*pvec ) { // ... // 按长度排序 texts 的元素 // 保留元素的原始顺序 stable_sort( texts.begin(), texts.end(), less_than ); // ... } 尽管这样已经完成了工作 但是比我们期望的效率要低得多 less_than()是作为单个语句 而实现的 正常情况下 它应该被用作 inline 函数调用 但是 把它用作函数指针来传递 又阻止了它被 inline 的可能 替代的策略是使用函数对象来保留操作的 inline 特性 例如 // 函数对象——小于操作被实现为 operator()的一个实例 class LessThan { public: bool operator()( const string & s1, const string & s2 ) { return s1.size() < s2.size(); } }; 函数对象是一个类 它重载了调用操作符 () 调用操作符的函数体实现了函数的功 能 小于比较 调用操作符的定义第一次看有点迷惑 因为出现了两个小括号 如下序列 operator() 告诉编译器我们在重载调用操作符 第二对括号 ( const string & s1, const string & s2 ) 指定了传递给调用操作符的重载实例的形式参数 如果比较这个定义和前面的 less_than() 定义 我们注意到除了用 operator()代替 less_than 之外 这两个定义完全一样 函数对象的定义方式与普通类对象一样 虽然在这种情况下 我们不必定义构造函数 没 有数据成员需要被初始化 LessThan lt; 为了调用被重载的调用操作符 我们只是简单地把调用操作符应用在我们的类对象上 并向它提供必要的参数 例如 string st1( "shakespeare" ); string st2( "marlowe" ); // 调用 lt.operator()( st1, st2 ); bool is_shakespeare_less = lt( st1, st2 ); 下面是 process_vocab()的重新实现 这次我们向 stable_sort()传递了一个 LessThan 函数 对象 void process_vocab( vector< textwords >*pvec ) { // ... stable_sort( texts.be gin(), texts.end(), LessThan() ); 475 第十二章 泛型算法 // ... } 在 stable_sort()中 重载的调用操作符已经被内联展开 stable_sort()能够接受的第三个 实参可以是函数 less_than()的指针 也可以是类 LessThan 的对象 因为该实参是一个模板机 制的类型参数 我们将在 12.3 节更详细地了解函数对象 下面是 texts 的 stable_sort()的结果 a i as at by he in is it no of on so to and but for has her him its not now one red row she sky the you asks bird blue coat dark each emma fear grow hair held home life like long mean more puts same says star such tell that time what when wind with your alice alive blows daddy falls fiery lands leave looks quite rises shush shyly sight still stone tells there thing trees watch almost either flight houses night, ancient becomes bounded calling distant flowing heaven, magical passion through unravel untamed wanting darkened eternity beautiful darkening immensity journeying alternately immeasurable inexpressibly 我们的下一个任务是计数长度小于 6 个字符的单词的个数 我们可以通过 count_if()泛型 算法和第二个函数对象 GreatThan 来实现 GreatThan 是一个更加复杂的函数对象 因为我们 要把它泛化 以便允许用户提供一个用于比较操作的显式长度值 所以在缺省情况下 用长 度 6 初始化 #include class GreaterThan { public: GreaterThan( int sz = 6 ) : _size( sz ){} int size() { return _size; } bool operator()( const string & s1 ) { return s1.size() > _size; } private: int _size; }; 下面是它的用法 void process_vocab( vector< textwords >*pvec ) { // ... // 计数长度大于 6 的字符串个数 int cnt = count_if( texts.begin(), texts.end(), GreaterThan() ); cout << "Number of words greater than length six are " 476 第十二章 泛型算法 << cnt << endl; // ... } 下面是这部分程序的输出 Number of words greater than length six are 22 remove()的行为和 unique()相同 它并不是真正地改变容器的长度 而是把元素分成保留 的 把它们按顺序拷贝到容器的前面 和要删除的 它们留在后面 它返问一个指向要被 删除的第一个元素的 iterator 下面给出怎样用它来删除不希望留在 vector 中的常见词的集合 void process_vocab( vector< textwords >*pvec ) { // ... static string rw[] = { "and", "if", "or", "but", "the" }; vector< string > remove_words( rw, rw+5 ); vector::iterator it2 = remove_words.begin(); for ( ; it2 != remove_words.end(); ++it2 ) { // 只是显示其他格式的 count() int cnt = count( texts.begin(), texts.end(), *it2 ); cout << cnt << " instances removed: " << (*it2) << endl; texts.erase( remove(texts.begin(),texts.end(),*it2 ), texts.end() ); } // ... } 下面是 texts 的 remove()结果 1 instances removed: and 0 instances removed: if 0 instances removed: or 1 instances removed: but 1 instances removed: the 最后我们想显示 vector 的内容 一种方式是迭代元素 按顺序一个个地显示 因为这种 做法没有使用泛型算法 所以在这一节中不合适 我们更希望说明 for_each()泛型算法的用法 用它来输出 vector 的元素 for_each()把函数指针或函数对象应用在由一对 iterator 标记的容 器的每个元素上 在我们的例子中 函数对象 printElem 把元素输出到标准输出上 class PrintElem { public: PrintElem( int lineLen = 8 ) : _line_length( lineLen ), _cnt( 0 ) { } void operator()( const string &elem ) 477 第十二章 泛型算法 { ++_cnt; if ( _cnt % _line_length == 0 ) { cout << '\n'; } cout << elem << " "; } private: int _line_length; int _cnt; }; void process_vocab( vector< textwords >*pvec ) { // ... for_each( texts.begin(), texts.end(), PrintElem() ); } 就是这样 我们完成了程序 几乎没做什么 只是把一串泛型算法调用连接在一起 为 方便起见 我们在下面列出了完整的程序 并加上一个 main()函数驱动它 它提前使用了一 个将在 12.4 节讨论的特殊的 iterator 类型 我们列出了真正可执行的代码 它不完全是标 准 C++ 尤其是 count()和 count_if()算法提供的实现代表了一个旧版本 它不返回结果 而 是要求传递一个额外的 用来存放结果值的实参 另外 iostream 库也反映了一个在标准 C++ 之前的实现 因为它要求使用 iostream.h 头文件 #include #include #include #include // 标准 C++ 之前的 语法 #include class GreaterThan { public: GreaterThan( int sz = 6 ) : _size( sz ){} int size() { return _size; } bool operator()( const string &s1 ) { return s1.size() > _size; } private: int _size; }; class PrintElem { public: PrintElem( int lineLen = 8 ) : _line_length( lineLen ), _cnt( 0 ) 478 第十二章 泛型算法 {} void operator()( const string &elem ) { ++_cnt; if ( _cnt % _line_length == 0 ) { cout << '\n'; } cout << elem << " "; } private: int _line_length; int _cnt; }; class LessThan { public: bool operator()( const string & s1, const string & s2 ) { return s1.size() < s2.size(); } }; typedef vector textwords; void process_vocab( vector*pvec ) { if ( ! pvec ) { // 给出警告消息 return; } vector< string, allocator > texts; vector::iterator iter; for ( iter = pvec->begin(); iter != pvec->end(); ++iter ) copy( (*iter).begin(), (*iter).end(), back_inserter( texts )); // 排序 texts 的元素 sort( texts.begin(), texts.end() ); // ok, 我们来看一看我们有什么 for_each( texts.begin(), texts.end(), PrintElem() ); cout << "\n\n"; // 只是分隔显示输出 // 删除重复元素 vector::iterator it; it = unique( texts.begin(), texts.end() ); 479 第十二章 泛型算法 texts.erase( it, texts.end() ); // ok, 让我们来看一看现在我们有什么了 for_each( texts.begin(), texts.end(), PrintElem() ); cout << "\n\n"; // 根据缺省的长度 6 排序元素 // stable_sort() 保留相等元素的顺序 stable_sort( texts.begin(), texts.end(), LessThan() ); for_each( texts.begin(), texts.end(), PrintElem() ); cout << "\n\n"; // 计数长度大于 6 的字符串的个数 int cnt = 0; // count 的过时格式——标准 C++ 已经改变了它 count_if( texts.begin(), texts.end(), GreaterThan(), cnt ); cout << "Number of words greater than length six are " << cnt << endl; static string rw[] = { "and", "if", "or", "but", "the" }; vector remove_words( rw, rw+5 ); vector::iterator it2 = remove_wo rds.begin(); for ( ; it2 != remove_words.end(); ++it2 ) { int cnt = 0; // count 的过时格式——标准 C++ 已经改变了它 count( texts.begin(), texts.end(), *it2, cnt ); cout << cnt << " instances removed: " << (*it2) << endl; texts.erase( remove(texts.begin(),texts.end(),*it2), texts.end() ); } cout << "\n\n"; for_each( texts.begin(), texts.end(), PrintElem() ); } // difference_type 类型能够包含一个容器的两个 iterator 的减法结果 // ——在这种情况下, 是 string vector 的 ... // 通常, 被缺省处理 typedef vector::difference_type diff_type; // 标准 C++ 之前的头文件语法 #include 480 第十二章 泛型算法 main() { vector sample; vector t1, t2; string t1fn, t2fn; // 要求用户输入文件 // 实际中的程序应该做错误检查 cout << "text file #1: "; cin >> t1fn; cout << "text file #2: "; cin >> t2fn; // 打开文件 ifstream infile1( t1fn.c_str()); ifstream infile2( t2fn.c_str()); // iterator 的特殊形式 // 通常, diff_type 被缺省提供 istream_iterator< string, diff_type > input_set1( infile1 ), eos; istream_iterator< string, diff_type > input_set2( infile2 ); // iterator 的特殊形式 copy( input_set1, eos, back_inserter( t1 )); copy( input_set2, eos, back_inserter( t2 )); sample.push_back( t1 ); sample.push_back( t2 ); process_vocab( &sample ); } 练习 12.2 单词的长度不是衡量一个文本的复杂度的惟一或可能的最好标准 另外一种可能的测试 标准是句子的长度 请写一个程序 它读入一个文本文件 或者从标准输入读入 并为每个 句子生成一个字符串 vector 然后把每个 vector 传递给 count() 按复杂性显示句子 一种有 趣的做法是 把每个句子都作为长串存储在第一个字符串 vector 中 然后把该 vector 传递给 sort() 并提供一个函数对象 该函数对象提供了基于较短字符串的小于语义 要了解某个 特定的泛型算法的更详细描述或者其用法的进一步例子 请参见附录 它以字符顺序列出了 这些算法 练习 12.3 对一段文字更可靠的难度测试是句子的结构复杂性 把每个逗号记 1 点 每个冒号或分 号记 2 点 每个破折号记 3 点 修改练习 12.2 中的程序 计算每个句子的复杂性 用 count_if() 来确定句子 vector 中每个标点的出现次数 并按照复杂性顺序显示所有的句子 481 第十二章 泛型算法 12.3 函数对象 我们的 min()函数是模板机制的功能强大性与局限性的一个很好例子 template const Type& min( const Type *p, int size ) { int minIndex = 0; for ( int ix = 1; ix < size; ++ix ) if ( p[ ix ] < p[ minIndex ] ) minIndex = ix; return p[ minIndex ]; } 功能强大性来自于 定义一个 min()的单个实例 它就可以被实例化为无限种类型 的能 力 局限性在于 虽然 min()可以被实例化为无限种类型 但是它并不是对所有类型都完全适 用 局限性的焦点在于小于操作符的使用上 在一种情况下 底层类型可能不支持小于操作 符 例如 一个 Image 类可能不提供小于操作符的实现 虽然我们现在还不知道 但是以后 可能希望发现 Image 对象数组的最小帧数 但是用 Image 类数组来实例化 min()会导致编译时 刻错误 error: invalid types applied to the < operator: Image < Image 在第二种情况下 虽然存在小于操作符 但是提供的语义并不合适 例如 如果我们希 望找到最小的字符串 但是希望只考虑字母 并且不对大小写敏感 则虽然小于操作符被支 持 但支持的却是错误的语义 传统的方案是参数化比较操作符 在这种情况下声明一个函数指针 该函数有两个参数 并返回一个 bool 型的值 template < typename Type, bool (*Comp)(const Type&, const Type&)> const Type& min( const Type *p, int size, Comp comp ) { int minIndex = 0; for ( int ix = 1; ix < size; ++ix ) if ( Comp( p[ ix ], p[ minIndex ] )) minIndex = ix; return p[ minIndex ]; } 这种方案 与我们使用内置的小于操作符的第一个实现一起提供了对任何类型的一般性 支持 同时也包括我们的 Image 类 只要我们实现两个 Image 小于比较的语义 函数指针的 主要性能缺点是 它的间接引用使其不能被内联 对函数指针的替代策略是函数对象 我们在前面的例子中看到了大量的例子 函数对 象是一个类 它重载了函数调用操作符 operator() 该操作符封装了通常应该被实现为一 482 第十二章 泛型算法 个函数的动作 典型情况下 函数对象被作为实参传递给泛型算法 当然我们也可以定义独 立的函数对象 例如 如果把类 AddImage 定义为一个函数对象 它取两个图像 把它们合 成在一起 即把两个加在一起 然后返回一个图像 则我们可以这样定义 AddImages AI; 为了使函数对象执行其操作 我们应用调用操作符 提供必要的 Image 类操作数 例如 Image im1("foreground.tiff"), im2("background.tiff"); // ... // 调用 Image AddImages::operator()(const Image&,const Image&); Image new_image = AI( im1, im2 ); 函数对象与函数指针相比较 有两个方面的优点 首先 如果被重载的调用操作符是 inline 函数 则编译器能够执行内联编译 提供可能的性能好处 其次 函数对象可以拥有任意数 目的额外数据 用这些数据可以缓冲结果 也可以缓冲有助于当前操作的数据 下面是修改后的 min()实现 注意函数指针也可以用这个声明来传递 但是没有任何原型 检查 template < typename Type, typename Comp > const Type& min( const Type *p, int size, Comp comp ) { int minIndex = 0; for ( int ix = 1; ix < size; ++ix ) if ( Comp( p[ ix ], p[ minIndex ] )) minIndex = ix; return p[ minIndex ]; } 泛型算法一般支持两种形式来应用操作 使用内置 或可能是被重载的 操作符 和使 用函数指针或函数对象执行操作 函数对象从哪里来 一般来说 有三种来源 标准库预定义的一组算术 关系和逻辑函数对象 一组预定义的函数适配器 允许我们对预定义的函数对象 甚至于任何函数对象 进行特殊化或者扩展 我们可以定义自己的函数对象 将其传递给泛型算法 或将它们传给函数适配器 本节 我们将按顺序了解这三种函数对象 12.3.1 预定义函数对象 预定义函数对象被分成算术 关系和逻辑操作 每个对象都是一个类模板 其中操作数 的类型被参数化 为了使用它们 我们必须包含下列头文件 #include 例如 支持加法的函数对象是一个名为 plus 的类模板 为定义一个可以把两个整数相加 的实例 我们可以这样写 483 第十二章 泛型算法 #include plus< int > intAdd; plus< int > int Add; 为了调用加法操作 我们把重载的调用操作符应用在 intAdd 上 就像在上节中我们对 AddImage 类所做的那样 int ival1 = 10, ival2 = 20; // 等价于 int sum = ival1 + ival2; int sum = intAdd( ival1, ival2 ); 类模板 plus 的实现调用了与其参数 int 类型相关联的加法操作符 这个类和其他预定义 的类的函数对象的主要用法是作为泛型算法的实参 通常被用来改变缺省的操作 例如 缺 省情况下 sort()用底层元素类型的小于操作符以升序排列容器的元素 为了以降序排列容器 我们传递预定义的类模板 greater 它调用底层元素类型的大于操作符 vector< string > svec; // ... sort( svec.begin(), svec.end(), greater() ); 预定义的函数对象被分成算术 关系和逻辑三大类别 分别被列在下面的小节中 每个 类对象都可以作为有名对象 也可以作为无名对象传递给一个函数 在下面的小节中分别进 行了说明 我们使用下面的对象定义 包括一个简单类的定义 关于操作符重载将在第 15 章详细讨论 class Int { public: Int( int ival = 0 ) : _val( ival ){} int operator-() { return -_val; } int operator%(int ival) { return _val % ival; } bool operator<(int ival) { return _val < ival; } bool operator!() { return _val == 0; } private: int _val; }; vector< string > svec; string sval1, sval2, sres; complex cval1, cval2, cres; int ival1, ival2, ires; Int Ival1, Ival2, Ires; double dval1, dval2, dres; 另外 我们还定义了下列两个函数模板 我们向其传递各种没有名字的函数对象 template Type UnaryFunc( FuncObject fob, const Type &val ) { return fob( val ); } template Type BinaryFunc( FuncObject fob, 484 第十二章 泛型算法 const Type &val1, const Type &val2 ) { return fob( val1, val2 ); } 12.3.2 算术函数对象 预定义的算术函数对象支持加 减 乘 除 求余和取反 调用的操作符是与 Type 相 关联的实例 对一个 class 类型 如果它提供了该操作符的重载实例 则调用该实例 加法 plus plus stringAdd; // 调用 string::operator+() sres = stringAdd( sval1, sval2 ); dres = BinaryFunc( plus(), dval1, dval2 ); 减法 minus minus intSub; ires = intSub( ival1, ival2 ); dres = BinaryFunc( minus(), dval1, dval2 ); 乘法 multiplies multiplies complexMultiplies; cres = complexMultiplies( cval1, cval2 ); dres = BinaryFunc( multiplies(), dval1, dval2 ); 除法 divides divides intDivides; ires = intDivides( ival1, ival2 ); dres = BinaryFunc( divides(), dval1, dval2 ); 求余 modulus modulus IntModulus; Ires = IntModulus( Ival1, Ival2 ); ires = BinaryFunc( modulus(), ival2, ival1 ); 取反 negate negate intNegate; ires = intNegate( ires ); Ires = UnaryFunc( negate(), Ival1 ); 12.3.3 关系函数对象 预定义的关系函数对象支持等于 不等于 大于 大于等于 小于和小于等于 等于 equal_to equal_to stringEqual; sres = stringEqual( sval1, sval2 ); ires = count_if( svec.begin(), svec.end(), equal_to(), sval1 ); 不等于 not_equal_to 485 第十二章 泛型算法 not_equal_to complexNotEqual; cres = complexNotEqual( cval1, cval2 ); ires = count_if( svec.begin(), svec.end(), not_equal_to(), sval1 ); 大于 greater greater intGreater; ires = intGreater( ival1, ival2 ); ires = count_if( svec.begin(), svec.end(), greater(), sval1 ); 大于等于 greater_equal greater_equal doubleGreaterEqual; dres = doubleGreaterEqual( dval1, dval2 ); ires = count_if( svec.begin(), svec.end(), greater_equal(), sval1 ); 小于 less less IntLess; Ires = IntLess( Ival1, Ival2 ); ires = count_if( svec.begin(), svec.end(), less(), sval1 ); 小于等于 less_equal less_equal intLessEqual; ires = intLessEqual( ival1, ival2 ); ires = count_if( svec.begin(), svec.end(), less_equal(), sval1 ); 12.3.4 逻辑函数对象 预定义的逻辑函数对象支持逻辑与 两个操作数都为 true 时结果值为 true——应用与 Type 相关联的&& 逻辑或 两个操作数中有一个为 true 返回 true——应用与 Type 相关联 的|| 和逻辑非 操作数为 false 则返回 true——应用与 Type 相关联的!操作符 逻辑与 logical_and logical_and intAnd; ires = intAnd( ival1, ival2 ); dres = BinaryFunc( logical_and(), dval1, dval2 ); 逻辑或 logical_or logical_or intSub; ires = intSub( ival1, ival2 ); dres = BinaryFunc( logical_or(), dval1, dval2 ); 逻辑非 logical_not logical_not IntNot; Ires = IntNot( Ival1, Ival2 ); dres = UnaryFunc( logical_not(), dval1 ); 486 第十二章 泛型算法 12.3.5 函数对象的函数适配器 标准库还提供了一组函数适配器 用来特殊化或者扩展一元和二元函数对象 适配器是 一种特殊的类 它被分成下面两类 1 绑定器 binder binder 通过把二元函数对象的一个实参绑定到一个特殊的值上 将其转换成一元函数对象 例如 为了计数一个容器中小于或等于 10 的元素的个数 我们可 能会向 count_if()