Visual C++ 2012入门经典(第6版)


Visual C++ 2012 入门经典 (第 6 版) [美] Ivor Horton 著 苏正泉 李文娟 译 北 京 Ivor Horton Ivor Horton's Beginning Visual C++ 2012 EISBN:978-1-1183-6808-4 Copyright © 2012 by Wiley Publishing, Inc. All Rights Reserved. This translation published under license. 本书中文简体字版由 Wiley Publishing, Inc. 授权清华大学出版社出版。未经出版者书面许可,不得以任何方式 复制或抄袭本书内容。 北京市版权局著作权合同登记号 图字:01-2012-8287 本书封面贴有 Wiley 公司防伪标签,无标签者不得销售。 版权所有,侵权必究。侵权举报电话:010-62782989 13701121933 图书在版编目(CIP)数据 Visual C++ 2012 入门经典(第 6 版) / (美) 霍尔顿(Horton, I.) 著;苏正泉,李文娟 译. —北京:清华大学出版社,2013.5 书名原文:Ivor Horton's Beginning Visual C++ 2012 ISBN 978-7-302-31900-9 Ⅰ. ①V… Ⅱ. ①霍… ②苏… ③李… Ⅲ. ①C 语言-程序设计 Ⅳ. ①TP312 中国版本图书馆 CIP 数据核字(2013)第 074802 号 责任编辑:王 军 于 平 装帧设计:牛静敏 责任校对:成凤进 责任印制: 出版发行:清华大学出版社 网 址:http://www.tup.com.cn,http://www.wqbook.com 地 址:北京清华大学学研大厦 A 座 邮 编:100084 社 总 机:010-62770175 邮 购:010-62786544 投稿与读者服务:010-62776969,c-service@tup.tsinghua.edu.cn 质 量 反 馈:010-62772015,zhiliang@tup.tsinghua.edu.cn 印 刷 者: 装 订 者: 经 销:全国新华书店 开 本:185mm×260mm 印 张:48 字 数:1321 千字 版 次:2013 年 5 月第 1 版 印 次:2013 年 5 月第 1 次印刷 印 数:1~5000 定 价:98.00 元 ——————————————————————————————————————————————— 产品编号: 作 者 简 介 Ivor Horton 原来是一位数学家,却因向往信息技术工作轻松而收入丰厚,因而涉足信息技术领 域。尽管现实情况常常是工作辛苦而收入却相对不高,但他仍坚持从事计算机工作至今。在不同的 时期,他从事过的工作包括程序设计、系统设计、顾问工作以及管理和实现相当复杂的项目。 Horton 在计算机系统的设计和实现方面,拥有多年的工作经验,这些系统应用于多种行业的工 程设计和制造运营。他不仅能运用多种编程语言开发特殊用途的应用程序,而且还为科研人员和工 程人员提供教学,以帮助他们完成这类工作,在这些方面他都拥有相当丰富的经验。他多年来一直 从事程序设计方面书籍的撰写工作,目前出版的著作有 C、C++和 Java 等教程。目前,他既没有忙 于写书,也不提供咨询服务,而是在钓鱼、旅游和尽情地享受生活。 技术编辑简介 Marc Gregoire 是来自比利时的一位软件工程师。他毕业于比利时天主教鲁汶大学,获得了 “Burgerlijk ingenieur in de computer wetenschappen”学位(等同于计算机科学工程的科学硕士学位 )。 此后,他以优异成绩获得了同一所大学的人工智能硕士学位,并开始供职于一家大型软件咨询公司 (Ordina 公司网址: http://www.ordina.be)。他在西门子和诺基亚西门子通信公司为大型电信运营商开 发运行于 Solaris 上至关重要的 2G 和 3G 软件,这需要在国际团队中工作,包括南美、 USA、EMEA 和亚洲。现在,Marc 在尼康公司开发三维扫描软件。 他主要擅长 C/C++,具体地说就是 Microsoft VC++和 MFC framework。除 C/C++之外,他也喜 欢 C#,并使用 PHP 制作网页。除主要致力于 Windows 开发以外,他在开发全天候运行在 Linux 平 台上的 C++程序方面,也具有一定的经验,例如 EIB 家用自动控制和监视软件。 由于在 Visual C++方面具有杰出的专业技能, Marc Gregoire 自从 2007 年 4 月开始, 每年都荣获 了 Microsoft MVP(Most Valuable Professional)大奖。 Marc 不仅是 Belgian C++用户组(www.becpp.org)的创始人和 CodeGuru 论坛的活跃分子 (会员 名是 Marc G)。他制作的免费程序和共享程序通过其站点 www.nuonsoft.com 来发布,并维护其博客 www.nuonsoft.com/blog/。 致 谢 在为本书的出版而付出劳动的所有人员组成的大型团队中,作者只是其中的一员。感谢 John Wiley & Sons 公司和 Wrox 出版社的编辑和生产团队,感谢他们自始至终提供的帮助和支持。 尤其要感谢技术编辑 Marc Gregoire,感谢他审阅本书,并认真核对了书中提供的所有代码段和 示例。他对以更好的 方式呈现书中内容提出了很多建设性意见和建议,这毫无疑问使本书成为一 本更出色的教程。 感谢妻子 Eve 一如既往的爱和支持,这为我在从事其他工作的同时撰写完成本书奠定了基础。 感谢妻子为我提供的一切, 虽然我的工作负担对家庭生活造成了一定的影响, 但妻子对此默默忍耐, 并保持愉快。 前 言 欢迎使用本书。 通过学习本书, 您可以使用 Microsoft 公司最新的应用程序开发系统 Visual Studio 2012,成为优秀的 C++程序员。本书旨在讲述 C++程序设计语言,然后讲述如何运用 C++语言开发 自己的 Windows 应用程序。在此过程中,读者将了解这一最新 Visual C++版本所提供的很多激动人 心的新功能。 Visual C++ 2012 是 Microsoft 开发环境 Visual Studio 2012 的所有版本的一部分, 本书提到 Visual C++时,都是指 Visual Studio 2012 包含的 Visual C++ 2012 功能。 0.1 本书读者对象 本书针对任何想要学习如何使用 Visual C++编写在 Microsoft Windows 操作系统下运行的 C++ 应用程序的读者。 阅读本书不需要预先具备任何特定编程语言的知识。 如果属于下列 3 种情形之一, 您就适合学习本教程: ● 属于编程新手,十分渴望投入编程世界,并最终掌握 C++。要取得成功,您至少需要对计算 机的工作原理有大体的理解——包括内存的组织方式以及数据和指令的存储方式。 ● 具备一些其他语言的编程经验,如 BASIC;渴望学习 C++,并想提升实际的 Microsoft Windows 编程技能。 ● 有一些使用 C 语言或 C++语言的经验,但使用环境不是 Microsoft Windows;希望使用最新 的工具和技术,扩展在 Windows 环境下编程的技能。 0.2 本书主要内容 本书的第一部分通过一个详细的循序渐进式教程,讲授了使用 Visual Studio 2012 支持的 C++语 言技术编写 C++程序的基础知识。您将了解 ISO/IEC C++语言的语法和用法,并通过一系列范围广 泛的可工作示例,获得实际运用它的经验和信心。本书也提供了一些练习,可以检验所学的知识, 并且可以下载练习题答案。 当然, 本语言教程也介绍和说明了 C++标准库功能的用法, 因为开发程序时极有可能使用它们。 随着深入地学习 C++语言,您的标准库知识会不断增加。还将学习标准模板库 (Standard Template Library,STL)提供的强大工具。 对 C++的运用有信心之后,就可以继续学习 Windows 编程了。通过创建超过 2000 行代码的大 型可运行的应用程序,学习如何使用 MFC 来开发本地 Windows 应用程序。开发此应用程序贯穿多 章内容,用到了 MFC 提供的一系列用户界面功能。还要学习如何使用 Windows 8 UI 编写应用程序 的基础知识,并开发了一个示例。 Visual C++ 2012 入门经典(第 6 版) IV 0.3 本书结构 本书内容的结构安排如下: ● 第 1 章介绍使用 C++编写程序所需要理解的基本概念,以及在 Visual C++开发环境中体现 的主要思想,还叙述了如何使用 Visual C++的功能来创建本书其余部分要学习的各种 C++ 应用程序。 ● 第 2~9 章讲授 C++语言。首先是简单的过程式程序示例,然后学习类和面向对象的编程。 第 10 章介绍如何使用标准模板库 (Standard Template Library,STL)。STL 是一组功能强大且 全面的工具,用来组织和操作 C++程序中的数据。由于 STL 是独立于应用程序的,因此可 以在上下文中大量应用它。 ● 第 11 章讨论 Microsoft Windows 桌面应用程序的组织方式,并描述和展示了在所有为 Windows 操作系统编写的桌面应用程序中都存在的基本元素。本章解释了以 C++语言编写 的、使用 Windows API 和 MFC 的 Windows 应用程序示例,还给出了一个 Windows 应用程 序的基础示例。 ● 第 12~17 章讲述 Windows 桌面应用程序的编程。详细描述了如何使用 MFC 提供的构建 GUI 的功能编写 C++ Windows 应用程序。我们将学习如何创建并使用通用控件来构建应用程序 的图形用户界面,还将学习如何处理因用户与程序的交互作用而产生的事件。除了学习构 建 GUI 的技术以外,还将从开发该应用程序的过程中学到如何打印文档,以及如何在磁盘 上保存应用程序数据。 ● 第 18 章讲述为 Windows 8 编写应用程序的基本概念。 本书各章内容都包括许多工作示例,通过这些示例阐明所讨论的编程技术。每章结束时都总结 了该章所讲述的要点,大多数章节都在最后给出了一组练习,您可以应用所学的技术来试着解答这 些练习。练习的答案连同书中的所有代码都可以从 Wrox 出版社的网站上下载。 0.4 使用本书的前提 Visual Studio 2012 有几个版本, 它们都有不同的功能。 只有安装了 Visual Studio 2012 和 Windows 8,才能使用 Windows 8 UI 开发应用程序。下面是使用本书各部分的要求: 如果安装了 Visual Studio 11 的免费版本和 Windows 8,才能使用本书的所有示例和练习。 Visual Studio 2012 最便宜的付费版本是专业版。这个版本或更高版本及 Windows 8 比较适合学习本书。 如果安装了 Visual Studio 2012 专业版或更高版本和 Windows 7,就可以使用第 1~17 章的使用示 例和练习, 但第 18 章不行。 第 18 章介绍了使用 Windows 8 UI 的应用程序, 这需要安装 Visual Studio 2012 和 Windows 8。 Windows 8 有一个免费的 Visual Studio 2012 Express 版本,但它不足以运行本书的许多示例。第 12~17 章的例子都不能用 Visual Studio 2012 Express 版本编译。 前 言 V 0.5 源代码 读者在阅读本书提供的代码时,既可以亲自输入所有代码,也可以使用随书提供的代码文件。 本书所有代码均可以从 http://www.wrox.com/或 http://www.tupwk.com.cn/downpage 网站下载。 进入该 网站后,读者可以根据本书的书名查找本书 (既可以使用搜索框,也可以使用书名列表进行查找 ), 然后单击本书详细内容页面上提供的 Download Code 链接,就可以下载本书提供的所有代码。 注意: 由于许多书籍名称与本书类似,读者也可以通过 ISBN 进行查找,本书的 ISBN 为: 978-0-111-36808-4。 另外,读者可以从前面提到的 CodePlex 网站下载本书或其他 Wrox 书籍的代码,也可以从 Wrox 的 代码下载页面 http://www.wrox.com/dynamic/books/download.aspx 和http://www. tupwk.com.cn/downpage 下 载本书或其他 Wrox 书籍的代码。 源代码下载成功后,读者用任一解压工具将其解压即可。 0.6 勘误表 为了避免本书文字和代码中存在错误,我们已经竭尽全力。然而,世界上并不存在完美无缺的 事物,所以本书可能仍然存在错误。如果读者在我们编写的某本书籍中发现了诸如拼写错误或代码 缺陷等问题,那么请告诉我们,我们对此表示感谢。利用勘误表反馈错误信息,可以为其他读者节 省大量时间,同时,我们也能够受益于读者的帮助,这样有助于我们编写出质量更高的专业著作。 如果读者需要参考本书的勘误表, 请在网站 http://www.wrox.com 中用搜索框或书名列表查找本 书书名。然后,在本书的详细内容页面上,单击 Book Errata 链接。在随后显示的页面中,读者可以 看到与本书相关的所有勘误信息,这些信息是由读者提交、并由 Wrox 的编辑们加上的。通过访问 http://www.wrox.com/misc-pages/booklist.shtml,读者还可以看到 Wrox 出版的所有书籍的勘误表。 如果读者没有在 Book Errata 页面上找到自己发现的错误,那么请转到页面 http://www. wrox.com/contact/techsupport.shtml,针对您所发现的每一项错误填写表格,并将表格发给我们,我们 将对表格内容进行认真审查,如果确实是我们书中的错误,我们将在该书的 Book Errata 页面上标明 该错误信息,并在该书的后续版本中改正。 0.7 关于 p2p.wrox.com 论坛 如果读者希望能够与作者进行讨论,或希望能够参与到读者的共同讨论中,那么请加入 p2p.wrox.com 论坛。该论坛是一个基于 Web 的系统,读者可以在论坛发表与 Wrox 出版的书籍及相 关技术的信息,并与其他读者和技术用户进行讨论。论坛提供了订阅功能,可以将与读者所选定主 题相关的新帖子定期发送到读者的电子邮箱。 Wrox 的作者、编辑、业界专家,以及其他读者都会 参与论坛中的讨论。 Visual C++ 2012 入门经典(第 6 版) VI 读者可以在 http://p2p.wrox.com 参与多个论坛的讨论,这些论坛不仅能够帮助读者更好地理解 本书,还有助于读者更好地开发应用程序。如果读者希望加入论坛,那么请按照以下步骤执行: (1) 进入 http://p2p.wrox.com 页面,单击 Register 链接。 (2) 阅读使用条款,然后单击 Agree 按钮。 (3) 填写必要的信息及可选信息,然后单击 Submit 按钮。 (4) 随后读者会收到一封电子邮件,邮件中说明了如何验证账户并完成整个加入过程。 读者无须加入 P2P 论坛即可阅读论坛消息,但如果需要发表主题或发表回复,那么必须加入论坛。 成功加入论坛后,读者就可以发表新主题了。此时,读者还可以回复其他用户发表的主题。读者 在任何时间都可以阅读论坛信息,如果需要论坛将新的信息发送到自己的电子邮箱,那么可以单击论 坛列表中论坛名称旁的 Subscribe to this Forum 图标完成这项功能设置。 如果读者需要获得更多与 Wrox P2P 相关的信息,请阅读 P2P FAQs,这样可以获得大量与 P2P 和 Wrox 出版的书籍相关的具体信息。阅读 FAQs 时,请单击 P2P 页面上的 FAQs 链接。 目 录 第 1 章 使用 Visual C++编程 .......................1 1.1 使用 Visual C++学习........................... 1 1.2 编写 C++应用程序.............................. 2 1.3 学习桌面应用程序的编程 .................. 2 1.3.1 学习 C++ ....................................................3 1.3.2 控制台应用程序........................................3 1.3.3 Windows 编程概念....................................3 1.4 集成开发环境简介 .............................. 5 1.4.1 编辑器.........................................................5 1.4.2 编译器.........................................................5 1.4.3 链接器.........................................................5 1.4.4 库.................................................................6 1.5 使用 IDE............................................... 6 1.5.1 工具栏选项.................................................7 1.5.2 可停靠的工具栏........................................8 1.5.3 文档.............................................................8 1.5.4 项目和解决方案........................................8 1.5.5 设置 Visual C++的选项 ......................... 16 1.5.6 创建和执行 Windows 应用程序........... 17 1.6 小结..................................................... 19 1.7 本章主要内容 .................................... 19 第 2 章 数据、变量和计算 ..........................21 2.1 C++程序结构 ..................................... 21 2.1.1 main()函数............................................... 28 2.1.2 程序语句.................................................. 28 2.1.3 空白.......................................................... 30 2.1.4 语句块...................................................... 30 2.1.5 自动生成的控制台程序......................... 31 2.2 定义变量 ............................................ 32 2.2.1 命名变量.................................................. 32 2.2.2 声明变量.................................................. 33 2.2.3 变量的初始值 ......................................... 34 2.3 基本数据类型 .................................... 34 2.3.1 整型变量.................................................. 35 2.3.2 字符数据类型 ......................................... 36 2.3.3 整型修饰符 ............................................. 37 2.3.4 布尔类型.................................................. 38 2.3.5 浮点类型.................................................. 38 2.3.6 C++中的基本类型.................................. 39 2.3.7 字面值...................................................... 39 2.3.8 定义数据类型的同义词 ........................ 40 2.4 基本的输入 /输出操作........................41 2.4.1 从键盘输入 ............................................. 41 2.4.2 到命令行的输出..................................... 41 2.4.3 格式化输出 ............................................. 42 2.4.4 转义序列.................................................. 43 2.5 C++中的计算 ......................................45 2.5.1 赋值语句.................................................. 45 2.5.2 算术运算.................................................. 45 2.5.3 计算余数.................................................. 50 2.5.4 修改变量.................................................. 50 2.5.5 增量和减量运算符................................. 51 2.5.6 计算的顺序 ............................................. 53 2.6 类型转换和类型强制转换 ................54 2.6.1 赋值语句中的类型转换 ........................ 55 2.6.2 显式类型转换 ......................................... 56 2.6.3 老式的类型强制转换............................. 57 2.7 AUTO 关键字 .....................................57 2.8 类型的确定 .........................................58 2.9 按位运算符 .........................................58 2.9.1 按位 AND 运算符.................................. 58 2.9.2 按位 OR 运算符 ..................................... 60 2.9.3 按位 EOR 运算符................................... 61 2.9.4 按位 NOT 运算符................................... 61 2.9.5 移位运算符 ............................................. 61 2.10 lvalue 和 rvalue .................................63 2.11 了解存储时间和作用域 ...................64 Visual C++ 2012 入门经典(第 6 版) VIII 2.11.1 自动变量 ............................................. 64 2.11.2 决定变量声明的位置 ........................ 66 2.11.3 全局变量 ............................................. 67 2.11.4 静态变量 ............................................. 70 2.12 具有特定值集的变量 ...................... 70 2.12.1 旧枚举 ................................................. 70 2.12.2 类型安全的枚举 ................................ 72 2.13 名称空间 .......................................... 74 2.13.1 声明名称空间..................................... 75 2.13.2 多个名称空间..................................... 76 2.14 小结................................................... 77 2.15 练习................................................... 77 2.16 本章主要内容 .................................. 78 第 3 章 判断和循环 .....................................79 3.1 比较数据值 ........................................ 79 3.1.1 if 语句....................................................... 80 3.1.2 嵌套的 if 语句......................................... 81 3.1.3 嵌套的 if-else 语句................................. 85 3.1.4 逻辑运算符和表达式............................. 87 3.1.5 条件运算符.............................................. 89 3.1.6 switch 语句 .............................................. 91 3.1.7 无条件转移.............................................. 94 3.2 重复执行语句块 ................................ 95 3.2.1 循环的概念.............................................. 95 3.2.2 for 循环的变体........................................ 97 3.2.3 while 循环..............................................104 3.2.4 do-while 循环 ........................................106 3.2.5 基于范围的循环...................................107 3.2.6 嵌套的循环............................................107 3.3 小结................................................... 110 3.4 练习................................................... 110 3.5 本章主要内容 ...................................111 第 4 章 数组、字符串和指针 .................... 113 4.1 处理多个相同类型的数据值 .......... 113 4.1.1 数组........................................................113 4.1.2 声明数组................................................114 4.1.3 初始化数组............................................117 4.1.4 使用基于范围的 for 循环....................118 4.1.5 字符数组和字符串处理 ......................119 4.1.6 多维数组................................................122 4.2 间接数据访问 ...................................125 4.2.1 指针的概念 ...........................................125 4.2.2 声明指针................................................125 4.2.3 使用指针................................................126 4.2.4 初始化指针 ...........................................127 4.2.5 sizeof 操作符.........................................132 4.2.6 常量指针和指向常量的指针..............134 4.2.7 指针和数组 ...........................................136 4.3 动态内存分配 ...................................142 4.3.1 堆的别名— —空闲存储器 ...................142 4.3.2 new 和 delete 操作符............................142 4.3.3 为数组动态分配内存...........................143 4.3.4 多维数组的动态分配...........................146 4.4 使用引用 ...........................................146 4.4.1 引用的概念 ...........................................147 4.4.2 声明并初始化 lvalue 引用...................147 4.4.3 在基于范围的 for 循环中使用 引用........................................................148 4.4.4 rvalue 引用.............................................148 4.5 字符串的库函数 ...............................149 4.5.1 确定以空字符结尾的字符串 的长度....................................................149 4.5.2 连接以空字符结尾的字符串..............150 4.5.3 复制以空字符结尾的字符串..............151 4.5.4 比较以空字符结尾的字符串..............152 4.5.5 搜索以空字符结尾的字符串..............152 4.6 小结 ...................................................154 4.7 练习 ...................................................155 4.8 本章主要内容 ...................................155 第 5 章 程序结构 (1) ..................................157 5.1 理解函数 ...........................................157 5.1.1 需要函数的原因...................................158 5.1.2 函数的结构 ...........................................158 5.1.3 替代的函数语法...................................161 5.1.4 使用函数................................................161 5.2 给函数传递实参 ...............................164 目 录 IX 5.2.1 按值传递机制 .......................................165 5.2.2 给函数传递指针实参...........................166 5.2.3 给函数传递数组...................................167 5.2.4 给函数传递引用实参...........................171 5.2.5 使用 const 修饰符.................................173 5.2.6 rvalue 引用形参 ....................................174 5.2.7 main()函数的实参 ................................176 5.2.8 接受数量不定的函数实参 ..................177 5.3 从函数返回值 .................................. 179 5.3.1 返回指针................................................179 5.3.2 返回引用................................................182 5.3.3 函数中的静态变量...............................184 5.4 递归函数调用 .................................. 186 5.5 小结................................................... 189 5.6 练习................................................... 189 5.7 本章主要内容 .................................. 189 第 6 章 程序结构 (2)..................................191 6.1 函数指针 .......................................... 191 6.1.1 声明函数指针 .......................................191 6.1.2 函数指针作为实参...............................194 6.1.3 函数指针的数组...................................196 6.2 初始化函数形参 .............................. 196 6.3 异常................................................... 198 6.3.1 抛出异常................................................199 6.3.2 捕获异常................................................200 6.3.3 重新抛出异常 .......................................201 6.3.4 MFC 中的异常处理 .............................202 6.4 处理内存分配错误 .......................... 203 6.5 函数重载 .......................................... 204 6.5.1 函数重载的概念...................................204 6.5.2 引用类型和重载选择...........................207 6.5.3 何时重载函数 .......................................207 6.6 函数模板 .......................................... 208 6.7 使用 decltype 操作符 ....................... 210 6.8 使用函数的示例 .............................. 212 6.8.1 实现计算器............................................212 6.8.2 从字符串中删除空格...........................215 6.8.3 计算表达式的值...................................216 6.8.4 获得项值................................................218 6.8.5 分析数....................................................219 6.8.6 整合程序................................................221 6.8.7 扩展程序................................................223 6.8.8 提取子字符串 .......................................224 6.8.9 运行修改过的程序...............................226 6.9 小结 ...................................................227 6.10 练习 .................................................227 6.11 本章主要内容 .................................228 第 7 章 自定义数据类型 ...........................229 7.1 C++中的结构 ....................................229 7.1.1 结构的概念 ...........................................230 7.1.2 定义结构................................................230 7.1.3 初始化结构 ...........................................230 7.1.4 访问结构的成员...................................231 7.1.5 伴随结构的智能感知帮助 ..................234 7.1.6 RECT 结构............................................235 7.1.7 使用指针处理结构...............................236 7.2 数据类型、对象、类和实例 ..........237 7.2.1 类的起源................................................239 7.2.2 类的操作................................................239 7.2.3 术语........................................................240 7.3 理解类 ...............................................240 7.3.1 定义类....................................................240 7.3.2 声明类的对象 .......................................241 7.3.3 访问类的数据成员...............................241 7.3.4 类的成员函数 .......................................243 7.3.5 成员函数定义的位置...........................245 7.3.6 内联函数................................................245 7.4 类构造函数 .......................................246 7.4.1 构造函数的概念...................................247 7.4.2 默认的构造函数...................................248 7.4.3 默认的形参值 .......................................250 7.4.4 在构造函数中使用初始化列表..........252 7.4.5 声明显式的构造函数...........................253 7.5 类的私有成员 ...................................254 7.5.1 访问私有类成员...................................256 7.5.2 类的友元函数 .......................................257 Visual C++ 2012 入门经典(第 6 版) X 7.5.3 默认复制构造函数...............................259 7.6 this 指针............................................ 260 7.7 类的 const 对象................................ 263 7.7.1 类的 const 成员函数 ............................263 7.7.2 类外部的成员函数定义.......................264 7.8 类对象的数组 .................................. 265 7.9 类的静态成员 .................................. 267 7.9.1 类的静态数据成员...............................267 7.9.2 类的静态函数成员...............................270 7.10 类对象的指针和引用 .................... 270 7.10.1 类对象的指针 .....................................270 7.10.2 类对象的引用 .....................................273 7.11 小结................................................. 274 7.12 练习................................................. 274 7.13 本章主要内容 ................................ 275 第 8 章 深入理解类 ...................................277 8.1 类析构函数 ...................................... 277 8.1.1 析构函数的概念...................................277 8.1.2 默认的析构函数...................................278 8.1.3 析构函数与动态内存分配 ..................280 8.2 实现复制构造函数 .......................... 283 8.3 在变量之间共享内存 ...................... 284 8.3.1 定义联合................................................285 8.3.2 匿名联合................................................286 8.3.3 类和结构中的联合...............................286 8.4 运算符重载 ...................................... 287 8.4.1 实现重载的运算符...............................287 8.4.2 实现对比较运算符的完全支持..........290 8.4.3 重载赋值运算符...................................294 8.4.4 重载加法运算符...................................299 8.4.5 重载递增和递减运算符.......................303 8.4.6 重载函数调用操作符...........................304 8.5 对象复制问题 .................................. 305 8.5.1 避免不必要的复制操作.......................305 8.5.2 应用 rvalue 引用形参...........................308 8.5.3 命名的对象是 lvalue............................310 8.6 默认的类成员 .................................. 314 8.7 类模板 .............................................. 315 8.7.1 定义类模板 ...........................................316 8.7.2 根据类模板创建对象...........................318 8.7.3 使用有多个形参的类模板 ..................321 8.7.4 函数对象模板 .......................................323 8.8 完美转发 ...........................................324 8.9 使用类 ...............................................327 8.9.1 类接口的概念 .......................................327 8.9.2 定义问题................................................327 8.9.3 实现 CBox 类........................................328 8.10 组织程序代码 .................................343 8.11 字符串的库类 .................................345 8.11.1 创建字符串对象 ...............................345 8.11.2 连接字符串........................................346 8.11.3 访问与修改字符串...........................350 8.11.4 比较字符串........................................353 8.11.5 搜索字符串........................................356 8.12 小结 .................................................364 8.13 练习 .................................................364 8.14 本章主要内容 .................................365 第 9 章 类继承和虚函数 ...........................367 9.1 面向对象编程的基本思想 ..............367 9.2 类的继承 ...........................................368 9.2.1 基类的概念 ...........................................369 9.2.2 基类的派生类 .......................................369 9.3 继承机制下的访问控制 ...................372 9.3.1 派生类中构造函数的操作 ..................375 9.3.2 声明类的保护成员...............................378 9.3.3 继承类成员的访问级别 ......................380 9.4 派生类中的复制构造函数 ..............382 9.5 禁止派生类 .......................................384 9.6 友元类成员 .......................................385 9.6.1 友元类....................................................387 9.6.2 对类友元关系的限制...........................387 9.7 虚函数 ...............................................387 9.7.1 虚函数的概念 .......................................389 9.7.2 确保虚函数的正确执行 ......................391 9.7.3 禁止重写函数 .......................................391 9.7.4 使用指向类对象的指针 ......................392 目 录 XI 9.7.5 使用引用处理虚函数...........................393 9.7.6 纯虚函数................................................395 9.7.7 抽象类....................................................395 9.7.8 间接基类................................................398 9.7.9 虚析构函数............................................400 9.8 类类型之间的强制转换 .................. 403 9.9 嵌套类 .............................................. 403 9.10 小结................................................. 407 9.11 练习................................................. 407 9.12 本章主要内容 ................................ 409 第 10 章 标准模板库 ................................. 411 10.1 标准模板库的定义 ........................ 411 10.1.1 容器....................................................412 10.1.2 容器适配器.......................................414 10.1.3 迭代器 .............................................414 10.2 智能指针 ........................................ 415 10.3 算法................................................. 418 10.4 STL 中的函数对象 ........................ 418 10.5 STL 容器范围 ................................ 419 10.6 序列容器 ........................................ 419 10.6.1 创建矢量容器...................................420 10.6.2 矢量容器的容量和大小..................423 10.6.3 访问矢量中的元素 ..........................428 10.6.4 在矢量中插入和删除元素..............428 10.6.5 在矢量中存储类对象 ......................431 10.6.6 排序矢量元素...................................436 10.6.7 排序矢量中的指针 ..........................437 10.6.8 双端队列容器...................................442 10.6.9 使用列表容器...................................445 10.6.10 使用 forward_list 容器...................454 10.6.11 使用其他序列容器 ........................456 10.6.12 tuple< >类模板 ...............................466 10.7 关联容器 ........................................ 469 10.7.1 使用映射容器...................................469 10.7.2 使用多重映射容器 ..........................480 10.8 关于迭代器的更多内容 ................ 481 10.8.1 使用输入流迭代器 ..........................481 10.8.2 使用插入迭代器...............................484 10.8.3 使用输出流迭代器 ..........................485 10.9 关于函数对象的更多内容 ............487 10.10 关于算法的更多内容 ...................488 10.10.1 fill() ................................................489 10.10.2 replace().........................................489 10.10.3 find() ..............................................489 10.10.4 transform().....................................490 10.11 类型特质和静态断言 ...................491 10.12 λ表达式.......................................492 10.12.1 capture 子句..................................493 10.12.2 捕获特定的变量 ..........................494 10.12.3 模板和 λ 表达式 ..........................494 10.12.4 包装 λ 表达式...............................498 10.13 小结...............................................500 10.14 练习...............................................500 10.15 本章主要内容 ...............................501 第 11 章 Windows 编程的概念 .................503 11.1 Windows 编程基础.........................503 11.1.1 窗口的元素.......................................504 11.1.2 Windows 程序与操作系统..............505 11.1.3 事件驱动型程序...............................505 11.1.4 Windows 消息...................................506 11.1.5 Windows API.....................................506 11.1.6 Windows 数据类型 ..........................506 11.1.7 Windows 程序中的符号..................507 11.2 Windows 程序的结构 .....................508 11.2.1 WinMain()函数.................................509 11.2.2 消息处理函数...................................519 11.3 MFC.................................................524 11.3.1 MFC 表示法 .....................................524 11.3.2 MFC 程序的组织方式 .....................525 11.4 小结 .................................................528 11.5 本章主要内容 .................................528 第 12 章 使用 MFC 编写 Windows 程序....531 12.1 MFC 的文档/视图概念 ..................531 12.1.1 文档的概念.......................................531 12.1.2 文档界面...........................................532 12.1.3 视图的概念.......................................532 Visual C++ 2012 入门经典(第 6 版) XII 12.1.4 链接文档和视图...............................533 12.1.5 应用程序和 MFC.............................534 12.2 创建 MFC 应用程序 ..................... 535 12.2.1 创建 SDI 应用程序..........................536 12.2.2 MFC Application Wizard 的输出....539 12.2.3 创建 MDI 应用程序 ........................548 12.3 小结................................................. 549 12.4 练习................................................. 550 12.5 本章主要内容 0............................. 550 第 13 章 处理菜单和工具栏 .....................551 13.1 与 Windows 进行通信 ................... 551 13.1.1 了解消息映射...................................552 13.1.2 消息类别 ...........................................554 13.1.3 处理程序中的消息 ..........................554 13.2 扩展 Sketcher 程序........................ 555 13.3 菜单的元素 .................................... 556 13.4 为菜单消息添加处理程序 ............ 559 13.4.1 选择处理菜单消息的类..................560 13.4.2 创建菜单消息函数 ..........................560 13.4.3 编写菜单消息函数的代码..............562 13.4.4 添加更新菜单消息的处理 程序 ...................................................565 13.5 添加工具栏按钮 ............................ 568 13.5.1 编辑工具栏按钮的属性..................569 13.5.2 练习使用工具栏按钮 ......................570 13.5.3 添加工具提示...................................571 13.6 小结................................................. 571 13.7 练习................................................. 571 13.8 本章主要内容 ................................ 571 第 14 章 在窗口中绘图 .............................573 14.1 窗口绘图的基础知识 .................... 573 14.1.1 窗口工作区.......................................573 14.1.2 Windows 图形设备界面..................574 14.2 MFC 的绘图机制........................... 576 14.2.1 应用程序中的视图类 ......................576 14.2.2 CDC 类..............................................577 14.3 实际绘制图形 ................................ 585 14.4 对鼠标进行编程 ............................ 587 14.4.1 鼠标发出的消息...............................587 14.4.2 鼠标消息处理程序 ..........................588 14.4.3 使用鼠标绘图...................................590 14.5 绘制草图.........................................611 14.5.1 运行示例...........................................612 14.5.2 捕获鼠标消息...................................612 14.6 小结 .................................................613 14.7 练习题.............................................613 14.8 本章主要内容 .................................614 第 15 章 改进视图 .....................................615 15.1 Sketcher 应用程序的缺陷 ..............615 15.2 改进视图.........................................616 15.2.1 更新多个视图...................................616 15.2.2 滚动视图...........................................617 15.2.3 使用 MM_LOENGLISH 映射模式...........................................622 15.3 删除和移动元素 .............................622 15.4 实现上下文菜单 .............................623 15.4.1 关联菜单和类...................................624 15.4.2 选中上下文菜单项 ..........................625 15.5 标识位于光标下的元素 .................626 15.5.1 练习弹出菜单...................................627 15.5.2 突出显示元素...................................627 15.5.3 实现移动和删除功能......................631 15.6 处理屏蔽的元素 .............................637 15.7 小结 .................................................639 15.8 练习 .................................................639 15.9 本章主要内容 .................................639 第 16 章 使用对话框和控件 ......................641 16.1 理解对话框 .....................................641 16.2 理解控件.........................................642 16.3 创建对话框资源 .............................642 16.3.1 给对话框添加控件 ..........................643 16.3.2 测试对话框.......................................644 16.4 对话框的编程 .................................644 16.4.1 添加对话框类...................................644 16.4.2 模态和非模态对话框......................645 16.4.3 显示对话框.......................................646 目 录 XIII 16.5 支持对话框控件 ............................ 648 16.5.1 初始化对话框控件 ..........................648 16.5.2 处理单选按钮消息 ..........................649 16.6 完成对话框的操作 ........................ 650 16.6.1 给文档添加线宽...............................651 16.6.2 给元素添加线宽...............................651 16.6.3 在视图中创建元素 ..........................653 16.6.4 练习使用对话框...............................654 16.7 使用微调按钮控件 ........................ 655 16.7.1 添加 Scale 菜单项和工具栏 按钮 ...................................................655 16.7.2 创建微调按钮...................................655 16.7.3 生成比例对话框类 ..........................656 16.7.4 显示微调按钮...................................659 16.8 使用缩放比例 ................................ 660 16.8.1 可缩放的映射模式 ..........................660 16.8.2 设置文档的大小...............................661 16.8.3 设置映射模式...................................662 16.8.4 同时实现滚动与缩放 ......................663 16.9 使用状态栏 .................................... 665 16.9.1 给框架窗口添加状态栏..................665 16.9.2 CString 类..........................................669 16.10 使用编辑框控件 .......................... 669 16.10.1 创建编辑框资源..........................670 16.10.2 创建对话框类 ..............................671 16.10.3 添加 Text 菜单项.........................672 16.10.4 定义文本元素 ..............................672 16.10.5 实现 CText 类...............................673 16.11 小结............................................... 677 16.12 练习 .............................................. 678 16.13 本章主要内容 .............................. 678 第 17 章 存储和打印文档 .........................679 17.1 了解序列化 .................................... 679 17.2 序列化文档 .................................... 680 17.2.1 文档类定义中的序列化..................680 17.2.2 文档类实现中的序列化..................681 17.2.3 基于 CObject 的类的功能...............683 17.2.4 序列化的工作方式 ..........................684 17.2.5 如何实现类的序列化......................685 17.3 应用序列化 .....................................685 17.3.1 记录文档修改...................................686 17.3.2 序列化文档.......................................687 17.3.3 序列化元素类...................................689 17.4 练习序列化 .....................................693 17.5 打印文档.........................................694 17.6 实现多页打印 .................................697 17.6.1 获取文档的总尺寸 ..........................698 17.6.2 存储打印数据...................................698 17.6.3 准备打印...........................................699 17.6.4 打印后的清除...................................700 17.6.5 准备设备上下文...............................701 17.6.6 打印文档...........................................701 17.6.7 获得文档的打印输出......................705 17.7 小结 .................................................705 17.8 练习 .................................................705 17.9 本章主要内容 .................................706 第 18 章 编写 Windows 8 应用程序.........707 18.1 理解 Windows 8 应用程序 ............707 18.2 开发 WINDOWS 8 应用程序.......708 18.3 Windows Runtime 的概念 .............709 18.3.1 WinRT 名称空间..............................709 18.3.2 WinRT 对象 ......................................709 18.4 C++ COMPONENT EXTENSIONS (C++/CX) ........................................710 18.4.1 C++/CX 名称空间 ...........................710 18.4.2 定义 WinRT 类类型.........................711 18.4.3 Ref 类类型的变量............................713 18.4.4 访问 ref 类对象的成员....................713 18.4.5 事件处理函数...................................714 18.4.6 转换 ref 类引用的类型....................714 18.5 XAML.............................................714 18.5.1 XAML 元素......................................715 18.5.2 XAML 中的 UI 元素.......................716 18.5.3 附加属性...........................................719 18.5.4 父元素和子元素...............................719 18.5.5 控件元素...........................................719 Visual C++ 2012 入门经典(第 6 版) XIV 18.5.6 布局元素...........................................720 18.5.7 处理 UI 元素的事件........................720 18.6 创建 Windows 8 应用程序 ............ 721 18.6.1 应用程序文件...................................721 18.6.2 定义用户界面...................................722 18.6.3 创建标题 ...........................................724 18.6.4 添加游戏控件...................................726 18.6.5 创建包含纸牌的网格 ......................727 18.6.6 实现游戏的操作...............................732 18.6.7 初始化 MainPage 对象....................735 18.6.8 初始化一副纸牌...............................736 18.6.9 建立 cardGrid 的子元素..................736 18.6.10 初始化游戏.....................................738 18.6.11 洗牌..................................................740 18.6.12 突出显示 UI 纸牌..........................741 18.6.13 处理翻牌事件...................................741 18.6.14 处理图形事件.................................743 18.6.15 确认赢家.........................................745 18.6.16 处理游戏控件的按钮事件............746 18.7 缩放 UI 元素...................................747 18.8 平移 .................................................749 18.8.1 应用程序的启动动画......................749 18.8.2 故事板动画.......................................750 18.9 小结 .................................................752 18.10 本章主要内容 ...............................752 判断和循环 本章要点 ● 如何比较数据值 ● 如何基于比较结果来改变程序的执行序列 ● 如何使用逻辑运算符和表达式 ● 如何处理多选情形 ● 如何在程序中编写并使用循环 3.1 比较数据值 如果不希望作出武断的决定,那么我们需要一种比较机制。这种机制涉及一些新的运算符, 即关系运算符。 因为计算机中的所有信息最终都表示为数值 (第 2 章中已学习过如何用数字代码来表 示字符信息 ),所以数值比较实际上是所有判断的本质。总共有 6 个用于比较两个值的基本运算符, 如表 3-1 所示。 表 3-1 < 小于 <= 小于等于 > 大于 >= 大于等于 == 等于 != 不等于 表 3-1 中的各个运算符都对两个操作数的值进行比较,然后返回一个 bool 类型值:比较结果为 3 第 章 “等于”比较运算符有两个连续的“=”号,它与仅由一个“=”号组成的赋值运 算符不同。以赋值运算符代替“等于”比较运算符是常见的错误,因此务必注意这个 潜在的错误根源。 Visual C++ 2012 入门经典(第 6 版) 80 真返回 true,为假则返回 false。我们看几个简单的比较示例,就能明白这些运算符的工作过程。操 作数可以是变量、 字面值或表达式。 假设已经创建了两个整型变量 i 和 j,二者的值分别是 10 和–5, 那么表达式 i > j i != j j > -8 i <= j + 15 都将返回 true。 再假设已经定义了下面两个变量: char first = 'A', last = 'Z'; 下面几个比较示例使用了这两个字符变量: first == 65 first < last 'E' <= first first != last 上面 4 个表达式都涉及 ASCII 码值的比较。第一个表达式返回 true,因为 first 初始化为 'A',而 'A'的 ASCII 码值与十进制数 65 相等。第二个表达式检查 first 的值'A'是否小于 last 的值'Z'。如果在 附录 B 中查看这两个字符的 ASCII 码,就将注意到大写字母是用 65~90 的数值升序表示的,即 65 表示'A',90 表示'Z',因此第二个比较表达式同样返回 true。第三个表达式返回 false,因为'E'大于 first 的值。最后一个表达式返回 true,因为'A'肯定不等于'Z'。 考虑几个稍微复杂些的数值比较示例。下列语句定义了 4 个变量: int i = -10, j = 20; double x = 1.5, y = -0.25E-10; 观察下面这些表达式: -1 < y j < (10 - i) 2.0*x >= (3 + y) 可以看出,在比较时可以用结果为数值的表达式作为操作数。如果与第 2 章的运算符优先表进 行核对,就会发现上面的圆括号都不是必需的,但这些圆括号确实有助于使表达式更清楚。第一个 表达式为真,因此返回 bool 值 true。变量 y 的值是– 0.000 000 000 025,因此大于– 1。第二个比较 返回 false,因为表达式 10–i 的值是 20,与 j 相等。第三个表达式返回 true,因为表达式 3+y 略微 小于 3。 可以使用关系运算符来比较任何基本类型或枚举类型的数值, 因此现在所需的就是切实可行的、 用比较结果改变程序行为的办法。 3.1.1 if 语句 基本的 if 语句使程序在给定条件表达式的值为 true 时,执行一条语句或被大括号包围的语句块, 或者当条件为 false 时跳过该语句或语句块。执行过程如图 3-1 所示。 下面是一个简单的 if 语句示例: if('A' == letter) cout << "The first capital, alphabetically speaking."; 第 3 章 判断和循环 81 if(条件) 条件满足 //其他语句 条件不满足 //语句 { } 图 3-1 被测试的条件在紧跟关键字 if 的圆括号中,其后是条件为 true 时要执行的语句。注意这里分号 的位置,它位于 if 和圆括号内条件表达式后的那条语句之后,在圆括号包围的条件表达式后不应该 有分号,因为实质上这两行共同构成了一条语句。我们还看到 if 后面的语句是缩进编排的,其作用 是指出该语句仅当 if 条件返回 true 时才执行。缩进对程序执行而言不是必需的,但有助于读者了解 if 条件及依赖该条件的语句之间的关系。该代码段中的输出语句仅当变量 letter 的值为 'A'时才执行。 可以用下面的方法来扩展这个示例,即如果 letter 的值是'A',则改变该变量的值: if('A' == letter) { cout << "The first capital, alphabetically speaking."; letter = 'a'; } if 语句控制的语句块由大括号包围,本例中仅当条件 ('A' == letter)为 true 时才执行块中的语句。 如果没有大括号,则只有第一条语句从属于 if,而给 letter 赋值'a'的语句将总是执行。注意,块中每 条语句后面都有一个分号,但在块尾的大括号后面没有分号。块内可以有任意多条语句。现在,如 果 letter 的值为'A',则输出与以前相同的消息之后,该变量的值将修改为'a'。如果条件表达式返回 false,那么这两条语句都不会执行。 3.1.2 嵌套的 if 语句 当 if 语句中的条件为真时,要执行的语句同样可以是 if 语句。这种结构称作嵌套的 if 语句。只 有外部 if 语句的条件为 true 时,才测试内部 if 语句的条件。嵌套在一个 if 语句内部的 if 语句同样可 当使用= =运算符比较某种类型的变量和常量时,最好将常量写在= = 运算符的左 边,如'A' = = letter。这样,如果不小心写成'A' = letter,则编译器会给出错误消息。而 如果写成 letter = 'A' ,这是完全合法的,所以不会产生错误消息,尽管其实这并不是 您的本来意思。 Visual C++ 2012 入门经典(第 6 版) 82 以再包含另外一个嵌套的 if 语句。只要知道自己在做什么,通常可以无限制地嵌套 if 语句。 试一试:使用嵌套的 if 语句 下面的示例包含嵌套的 if 语句。 // Ex3_01.cpp // A nested if demonstration #include using std::cin; using std::cout; using std::endl; int main() { char letter(0); // Store input in here cout << endl << "Enter a letter: "; // Prompt for the input cin >> letter; // then read a character if(letter >= 'A') // Test for 'A' or larger { if(letter <= 'Z') // Test for 'Z' or smaller { cout << endl << "You entered a capital letter." << endl; return 0; } } if(letter >= 'a') // Test for 'a' or larger { if(letter <= 'z') // Test for 'z' or smaller { cout << endl << "You entered a small letter." << endl; return 0; } } cout << endl << "You did not enter a letter." << endl; return 0; } 示例说明 该程序开头照例是注释行,然后是 #include 语句,以嵌入支持输入 /输出的头文件,再后面是属 于 std 名称空间的 cin、cout 和 endl 的 using 声明。 main()函数体中的第一个动作是提示用户输入某 个字母,该字母存储在名为 letter 的 char 型变量中。 输入后面的 if 语句检查输入的字符是否大于等于 'A'。小写字母的 ASCII 码(97~122)大于大写字 母的 ASCII 码(65~90),所以输入小写字母也将使程序执行第一个 if 块,因为对任何字母而言,条件 第 3 章 判断和循环 83 表达式 (letter >='A')都将返回 true。在这种情况下,程序将执行检查输入是否小于等于 'Z'的嵌套 if。 如果该字母小于等于 'Z',那么当然是个大写字母,因此将显示相应的消息。我们的任务至此已经完 成,于是执行 return 语句来结束程序。这两条语句都被大括号包围,因此当嵌套的 if 条件返回 true 时都会执行。 下一条 if 语句检查输入的字符是否是小写字母,使用的机制与第一个 if 语句本质上相同,然后 显示消息并返回。 如果输入的字符不是字母,则执行最后一个 if 块后面的输出语句。该语句显示一条消息,大意 是输入的字符不是字母,然后执行 return 语句。 可以看出,嵌套 if 语句与输出语句之间的关系因使用了缩进而非常容易理解。 本示例的典型输出如下: Enter a letter: T You entered a capital letter. 只需要在检查输入是否为大写字母的 if 块后添加一条语句,就能将大写字母变为小写字母: if(letter >= 'A') // Test for 'A' or larger if(letter <= 'Z') // Test for 'Z' or smaller { cout << endl << "You entered a capital letter."; << endl; letter += 'a' - 'A'; // Convert to lowercase return 0; } 上面的代码添加了一条语句。这条将大写字母转换为小写字母的语句使变量 letter 的值增加了, 增加的量是 'a'–'A'。这是正确的,因为 A~Z 与 a~z 的 ASCII 码是两组连续的数值,分别是 65~90 和 97~122,所以表达式 'a'–'A'就是为得到等价小写字母而需要在大写字母的代码值上增加的数值,即 97–65=32。因此,如果在'K'的 ASCII 码值 75 上增加 32,就得到'k'的 ASCII 码值 107。 在这里,同样可以使用等价的 ASCII 码值来表示字母,但通过使用字母,我们能够确保所编写 的代码在不使用 ASCII 字符集的计算机上也能工作,唯一前提是大写和小写字母都是用连续的数值 序列表示的。 有一个将字母转换为大写字母的标准库函数,因此我们通常不必自己编写这种代码。该函数 的名称为 toupper(),位于标准头文件 ctype 中。当专门学习如何编写函数时,将介绍更多的标准库 函数。 扩展的 if 语句 迄今为止,我们所使用的 if 语句都仅在指定的条件返回 true 时才执行某些语句,之后,程序将 按顺序执行下一条语句。 另一种 if 版本是条件返回 true 时执行一条语句, 而条件返回 false 时执行另 一条语句。之后,程序将按顺序执行下一条语句。如第 2 章中所述,语句块总是能够代替一条语句, 该原则同样适用于 if 语句。 试一试:扩展的 if 语句 下面的示例包含扩展的 if 语句: Visual C++ 2012 入门经典(第 6 版) 84 // Ex3_02.cpp // Using the extended if #include using std::cin; using std::cout; using std::endl; int main() { long number(0L); // Store input here cout << endl << "Enter an integer number less than 2 billion: "; cin >> number; if(number % 2L) // Test remainder after division by 2 cout << endl // Here if remainder 1 << "Your number is odd." << endl; else cout << endl // Here if remainder 0 << "Your number is even." << endl; return 0; } 该程序的输出如下: Enter an integer less than 2 billion: 123456 Your number is even. 示例说明 将输入值读入 number 之后,我们通过求出除以 2 之后的余数 (使用第 2 章学习的求余数运算符 %)来测试输入值,并使用余数作为执行这条 if 语句的条件。在这种情况下, if 语句的条件返回的是 整数,而不是 bool 值。if 语句将条件返回的非零值解释为 true,将零值解释为 false。换句话说,这 条 if 语句的条件表达式 (number % 2L) 等价于 (number % 2L != 0) 如果余数是 1,则条件为 true,因此立即执行紧跟 if 的语句。如果余数是 0,则条件是 false,因 此执行紧跟 else 关键字的语句。在这里, if 表达式完成的工作一目了然。但是,对于复杂的表达式, 则最好再添加额外的几个字符与 0 比较一下,以确保代码容易理解。 在 if 语句中,条件可以是结果为任意基本数据类型(见第 2 章)的数值表达式。当 条件表达式的结果是数值而不是 if 语句所要求的 bool 值时,编译器自动将表达式的结 果强制转换为 bool 类型。强制转换为 bool 类型的非零值成为 true,而零值成为 false。 第 3 章 判断和循环 85 整数除以 2 得到的余数只能是 1 或 0。输出相应消息之后,执行 return 语句来结束程序。 if-else 组合是在两个选项中进行选择的,其一般逻辑如图 3-2 所示。 条件不满足 if(条件) 条件满足 // 其他语句 else // 语句 // 其他语句 { } { } 图 3-2 图 3-2 中的箭头指出了语句的执行顺序,这取决于 if 条件是返回 true 还是 false。 3.1.3 嵌套的 if-else 语句 如您所见,可以在 if 语句内嵌套 if 语句。同样,也可以在 if 语句内嵌套 if-else 语句,在 if-else 语句内嵌套 if 语句,以及在 if-else 语句内嵌套 if-else 语句。这种灵活性也很容易让人混淆程序,因 此需要看几个示例。下面的示例是在 if 语句内嵌套 if-else 语句。 if('y' == coffee) if('y' == donuts) cout << "We have coffee and donuts."; else cout << "We have coffee, but not donuts"; 仅当 coffee 的测试结果返回 true 时,才执行对 donuts 的测试,因此输出消息反映的是每种情况 下的正确状况,但这种嵌套结构很容易造成混淆。如果用不正确的缩进编写完全相同的代码,就可 能得到错误的结论: if('y' == coffee) 与该语句的 if 部分类似,关键字 else 后面也不跟分号。缩排还是用于指示不同语 句之间的关系。我们可以清楚地看出哪条语句对应于 true 或非零结果,哪条对应于 false 或零结果。我们应该在程序中始终缩排语句,以表明相应的逻辑结构。 Visual C++ 2012 入门经典(第 6 版) 86 if('y' == donuts) cout << "We have coffee and donuts."; else // This else is indented incorrectly cout << "We have no coffee..."; // Wrong! 这里的错误还容易看出来, 但在更复杂的 if 结构中,就需要记住哪个 if 拥有哪个 else 的规则。 对于复杂的情形,可以应用这条规则来处理。当编写程序时,使用大括号肯定能使代码更清楚。 在上面所示的简单情形中,大括号实际上不是必需的,但也可以将该示例写成如下形式: if('y' == coffee) { if('y' == donuts) cout << "We have coffee and donuts."; else cout << "We have coffee, but not donuts"; } 现在的程序应该是绝对清楚的。既然我们已经知道前面的规则,就很容易理解在 if-else 语句内 嵌套 if 的情形。 if('y' == coffee) { if('y' == donuts) cout << "We have coffee and donuts."; } else if('y' == tea) cout << "We have tea, but not coffee"; 这里的大括号是必需的。如果将其省略,则 else 属于第二个 if,即对 donuts 进行测试的 if。在 这类情况下,通常很容易忘记添加大括号,从而产生难以发现的错误。包含这类错误的程序可以正 确编译,有时甚至还能产生正确的结果。 如果删除本示例中的大括号, 则仅当 coffee 和 donuts 都等于 'y',从而不执行 if('y' = = tea)测试时, 才能得到正确结果。 下面是在 if-else 语句内嵌套 if-else 语句的示例。这种结构即使只有一级嵌套,看起来也可能非 常混乱。 if('y' == coffee) if('y' == donuts) cout << "We have coffee and donuts."; else cout << "We have coffee, but not donuts"; else if('y' == tea) cout << "We have no coffee, but we have tea, and maybe donuts..."; else else 总是属于前面最近的、还没有对应 else 的 if。 第 3 章 判断和循环 87 cout << "No tea or coffee, but maybe donuts..."; 即使有正确的缩进,该程序的逻辑也非常不明显。大括号不是必需的,因为前面学习的规则能 够验证每个 else 都属于正确的 if,但如果加上大括号,该程序看起来将更加清楚。 if('y' == coffee) { if('y' == donuts) cout << "We have coffee and donuts."; else cout << "We have coffee, but not donuts"; } else { if('y' == tea) cout << "We have no coffee, but we have tea, and maybe donuts..."; else cout << "No tea or coffee, but maybe donuts..."; } 有更好的方法来处理程序中的这种逻辑。如果将多个嵌套 if 语句放在一起,那么几乎肯定会在 某个地方产生错误。下面一节将有助于使问题简化。 3.1.4 逻辑运算符和表达式 如前所述, 当有两个或更多相关条件时, 使用 if 语句会比较麻烦。 在用 if 语句查找 coffee 和donuts 时,我们已经发挥出了自己的聪明才智,但是,实际上我们可能想要测试更为复杂的条件。 逻辑运算符提供了既简洁又方便的解决方案。使用逻辑运算符,可以将一系列比较组合成一个 逻辑表达式。因此,只要判断问题最终归结为在真假两种可能性之间选择,则无论条件集多么复杂, 最终需要的都只是一条 if 语句。 逻辑运算符只有 3 个: && 逻辑与 || 逻辑或 ! 逻辑非 1. 逻辑与 如果两个条件必须都是 true 时结果才为真,则可以使用“与 (AND)”运算符 &&。当两个操作数 的值都是 true 时,&&运算符的结果才是 true,否则结果是 false。 当测试某个字符以确定其是否为大写字母时,可以使用&&运算符,被测试的数值必须既大于 等于'A',又小于等于'Z'。这两个条件必须都返回 true,才能确定被测试的字符是大写字母。 如前所述,用逻辑运算符组合起来的条件可能返回数值。记住,这种情况下非零 值被强制转换为 bool 值 true,而零值被强制转换为 false。 Visual C++ 2012 入门经典(第 6 版) 88 还以在 char 型的变量 letter 中存储数值为例, 可以用仅包括一个 if 和&&运算符的表达式来代替 原来使用了两个 if 语句的测试条件。 if((letter >= 'A') && (letter <= 'Z')) cout << "This is a capital letter."; if 条件表达式内的圆括号确保比较操作首先执行,这样会使语句更清楚。输出语句仅当 &&运算 符组合的两个条件都为 true 时才执行。 2. 逻辑或 如果希望两个条件之一或全部为真时结果为 true,则应该使用“或 (OR)”运算符 ||。例如,只 有年收入至少为 100 000 美元,或者有 1 000 000 美元现金,银行才认为我们有资格申请贷款。下面 的 if 语句可以测试这个条件。 if((income >= 100000.00) || (capital >= 1000000.00)) cout << "How much would you like to borrow, Sir (grovel, grovel)?"; 当这两个条件中的一个或两个为 true 时,才会显示这条响应消息。只有当两个操作数的值都是 false 时,|| 运算符的结果才是 false。 3. 逻辑非 第三个逻辑运算符 ! 将某个 bool 类型操作数的值取反。 因此, 如果变量 test 的值为 true,则 !test 为 false;如果 test 为 false,则 !test 为 true。以一个简单的表达式为例,如果 x 的值是 10,则下面 的表达式 !(x > 5) 为 false,因为 x > 5 为 true。 还可以在 Charles Dickens 特别喜爱的一个表达式中应用 ! 运算符: !(income > expenditure) 如果该表达式为 true,则悲惨的生活至少从银行开始拒付支票那一刻起就降临了。 最后,可以对其他基本数据类型应用 ! 运算符。假设变量 rate 是 float 类型,值为 3.2。如果想 通过测试来证明 rate 的值不是零,则可以使用下面的表达式: 如果&&运算符左边的操作数是 false,则不再对右边的操作数求值。当右边的操作数 是一个会改变某些东西的表达式,如涉及++或--运算符的表达式时,这一特点就变得非 常有意义。例如,在表达式 x > =5 && ++n < 10 中,如果 x 小于等于 5,则 n 将不递增。 如果 || 运算符左边的操作数是 true,则不再对右边的操作数求值。例如,在表达式 x > =5 || ++n < 10 中,如果 x 大于等于 5,则变量 n 将不递增。 第 3 章 判断和循环 89 !(rate) 值 3.2 是非零值,因此转换为 bool 值 true,这样该表达式的结果就是 false。 试一试:组合逻辑运算符 只要认为合适,就可以任意地组合条件表达式和逻辑运算符。例如,可以仅仅使用一个 if 语句 就构造出测试某个变量是否包含某个字母的条件。下面将其写成一个可运行的示例: // Ex3_03.cpp // Testing for a letter using logical operators #include using std::cin; using std::cout; using std::endl; int main() { char letter(0); // Store input in here cout << endl << "Enter a character: "; cin >> letter; if(((letter >= 'A') & & (letter <= 'Z')) || ((letter >= 'a') & & (letter <= 'z'))) // Test for alphabetic cout << endl << "You entered a letter." << endl; else cout << endl << "You didn't enter a letter." << endl; return 0; } 示例说明 本示例首先在提示输入之后读取一个字符,这与 Ex3_01.cpp 完全相同。该程序中 if 语句的条件 比较有意思。该条件由两个用 || (或)运算符组合在一起的逻辑表达式组成,因此任何一个表达式为 true,该条件就返回 true,于是显示下面的消息: You entered a letter. 如果两个逻辑表达式都是 false,则执行 else 语句,显示下面的消息: You didn't enter a letter. 每个逻辑表达式又用 &&(与)运算符组合了一对比较条件,因此两个比较必须都返回 true,相应 的逻辑表达式才能是 true。如果输入大写字母,则第一个逻辑表达式返回 true;如果输入是小写字 母,则第二个表达式返回 true。 3.1.5 条件运算符 条件运算符有时称作三元运算符,因为它牵涉 3 个操作数。通过示例来理解该运算符应该是最 Visual C++ 2012 入门经典(第 6 版) 90 好的办法。假设有两个变量 a 和 b,我们希望将 a 和 b 中的最大值赋给第三个变量 c。可以用下面的 语句来实现: c = a > b ? a : b; // Set c to the maximum of a or b 条件运算符的第一个操作数必须是结果为 bool 值 true 或 false 的表达式,本例中的表达式是 a > b。 如果该表达式返回 true,则第二个操作数(本例中是 a)被选为结果值。如果第一个参数返回 false,则第 三个操作数(本例中是 b)被选为结果值。因此,如果 a 大于 b,则条件表达式 a > b ? a : b 的结果是 a; 否则是 b。作为赋值操作的结果,该数值存储在 c 中。该赋值语句中使用条件运算符等价于下面的 if 语句: if(a > b) c = a; else c = b; 条件运算符通常可以写成下面的形式: condition ? expression1 : expression2 如果 condition 为 true,则结果是 expression1 的值;如果 condition 为 false,则结果是 expression2 的值。 试一试:在输出中使用条件运算符 条件运算符的常见用途是根据表达式的结果或变量的值来控制输出。可以根据指定的条件,通 过选择文本字符串来改变输出的消息。 // Ex3_04.cpp // The conditional operator selecting output #include using std::cout; using std::endl; int main() { int nCakes(1); // Count of number of cakes cout << endl << "We have " << nCakes << " cake" << ((nCakes > 1) ? "s." : ".") << endl; ++nCakes; cout << endl << "We have " << nCakes << " cake" << ((nCakes > 1) ? "s." : ".") << endl; return 0; } 该程序的输出如下: We have 1 cake. We have 2 cakes. 第 3 章 判断和循环 91 示例说明 首先用数值 1 初始化变量 nCakes,然后是一条显示蛋糕数量的输出语句。使用条件运算符只是 想测试一下变量 nCakes,以确定是有一块还是多块蛋糕: ((nCakes>1) ? "s." : ".") 如果 nCakes 大于 1,则该表达式为 "s.";否则为"."。该表达式能够为任意数量的蛋糕使用同一 条输出语句,并得到语法上正确的输出。为证实这一点,在示例中使变量 nCakes 递增,然后重复执 行相同的输出语句。 还有许多其他可以应用这类机制的情况,例如,在"is"和"are"之间进行选择。 3.1.6 switch 语句 switch 语句能够基于给定表达式的一组定值,从多个选项中进行选择。其原理就像旋转开关一 样,可以从多个选项中选择其中一项,有些洗衣机就是以这种方式供用户操作的。开关上有多个位 置,如棉、毛料、合成纤维等,旋转手柄,指向需要的选项,就可以选择任意一个位置。 在 switch 语句中,作出的选择由指定表达式的值决定。可以用一个或多个 case 值来定义 switch 的位置。如果 switch 表达式的值与某个分情形值 (case 值)相同,则选择相应的 case 值。switch 语句 的每种选择对应一个 case 值,所有 case 值都不相同。 switch 语句的一般形式如下: switch(expression) { case c1: // One or more statements for c1... break; case c2: // One or more statements for c2... break; // More case statements... default: // Statements for default case... break; } switch 和 case 都是关键字, c1、c2 等都是整型常量,或编译器可以计算的、得到整型常量的表 达式,即不是必须在运行期间计算的表达式。 case 语句的顺序可以任意,每个 case 值都必须唯一, 才能让编译器区分它们。当 expression 计算为其中一个 case 值时,就执行该 case 语句后面的语句。 如果 switch 表达式的值不与任何 case 值匹配,则 switch 自动选择默认的 case 值。也可以省略 默认的 case 值,此时默认的 case 语句什么也不做。 在执行了某个 case 语句后,每个 case 语句末尾的 break 语句将程序的执行传递给 switch 块后面 的语句。如果没有这条语句,程序将继续执行下一个 case 语句,默认 case 后面的 break 不是必需的, 但最好包含它,以便于以后在默认 case 后面添加 case 语句。下面看看其工作过程。 试一试: switch 语句 通过下面的示例,可以分析 switch 语句的工作过程。 // Ex3_05.cpp Visual C++ 2012 入门经典(第 6 版) 92 // Using the switch statement #include using std::cin; using std::cout; using std::endl; int main() { int choice(0); // Store selection value here cout << endl << "Your electronic recipe book is at your service." << endl << "You can choose from the following delicious dishes: " << endl << endl << "1 Boiled eggs" << endl << "2 Fried eggs" << endl << "3 Scrambled eggs" << endl << "4 Coddled eggs" << endl << endl << "Enter your selection number: "; cin >> choice; switch(choice) { case 1: cout << endl << "Boil some eggs." << endl; break; case 2: cout << endl << "Fry some eggs." << endl; break; case 3: cout << endl << "Scramble some eggs." << endl; break; case 4: cout << endl << "Coddle some eggs." << endl; break; default: cout << endl << "You entered a wrong number, try raw eggs." << endl; } return 0; } 示例说明 在流输出语句中显示了输入选项,并将选择的数字读入变量 choice 之后, switch 语句开始按照 指定的条件执行,该条件是关键字 switch 后圆括号内的 choice。switch 中的选项包围在大括号之间, 分别用 case 标签来标识。 case 标签是关键字 case,加上后面跟着的与该选项对应的 choice 值,并以 冒号结束。 可以看出, 特定 case 下要执行的语句写在 Case 标签结束处冒号的后面, 以一条 break 语句结束。 break 语句将程序的执行传递给 switch 后面的语句。 break 不是必需的,但如果没有这条语句,程序 将继续执行后面的 case 语句,这通常不是我们想要的。可以试一下,看看删除本示例中的 break 语 句之后会发生什么事情。 对特定情形要执行的语句也可以用大括号括起来,有时必须这么做。例如,如果在 case 语句中 创建一个变量,则必须包含括号。下面的语句会导致错误消息: switch(choice) 第 3 章 判断和循环 93 { case 1: int count = 2; cout << "Boil " << count << " eggs." << endl; // Code to do something with count... break; default: cout << endl << "You entered a wrong number, try raw eggs." << endl; break; } 由于 count 变量可能未在 switch 块中初始化,因此会得到下面的错误消息: error C2360: initialization of 'count' is skipped by 'case' label 可以将前面这段代码修改为: switch(choice) { case 1: { int count = 2; cout << "Boil " << count << " eggs." << endl; // Code to do something with count... break; } default: cout << endl << "You entered a wrong number, try raw eggs." << endl; break; } 如果 choice 的值与指定的任何 Case 值都不符合,则执行 default 标签后面的语句。 Default Case 不是必需的。在缺少该 Case 的情况下,如果测试表达式的值与任何 case 都不符合,则退出 switch 语句,程序继续执行 switch 后面的语句。 试一试:共享某种 case switch 语句中的每个 case 表达式都必须是可以在编译期间计算的常量表达式,且必须是互不相 同的整数值。任何两个 case 常量都不能相同,原因是编译器将无法知道应该执行哪条 case 语句,但 是不同的 case 不一定要采取不同的动作。如下所示,若干 case 可以共享相同的动作。 // Ex3_06.cpp // Multiple case actions #include using std::cin; using std::cout; using std::endl; int main() { Visual C++ 2012 入门经典(第 6 版) 94 char letter(0); cout << endl << "Enter a small letter: "; cin >> letter; switch(letter*(letter >= 'a' & & letter <= 'z')) { case 'a': case 'e': case 'i': case 'o': case 'u': cout << endl << "You entered a vowel."; break; case 0: cout << endl << "That is not a small letter."; break; default: cout << endl << "You entered a consonant."; } cout << endl; return 0; } 示例说明 在本示例中,switch 语句中的表达式更为复杂。如果输入的字符不是小写字母,则表达式 (letter >= 'a' && letter <= 'z') 结果为 false;否则为 true。因为 letter 要乘以该表达式的值,所以该逻辑表达式的值被转换为整数。 如果是 false,则转换为 0;如果是 true,则转换为 1。因此,如果输入的不是小写字母,则 switch 表达式的值为 0;如果是小写字母,则该表达式的值就是 letter 的值。只要 letter 中存储的字符代码 不是小写字母,程序就执行 case 0 后面的语句。 如果输入的是小写字母,则 switch 表达式的值与 letter 的值相同。因此,对于所有对应元音的 值来说,输出语句紧随着把元音作为值的 case 标签的序列执行。无论输入的是哪个元音,执行的都 是同一条语句,因为选中这些 case 标签中的任何一个,都要执行后续的语句,直至遇到 break 语句 为止。我们看到,在要执行的语句之前接连写出各个 case 标签,就可以为多种不同的 case 采取相同 的动作。如果输入的小写字母是辅音,则执行 case 标签 default 后面的语句。 3.1.7 无条件转移 if 语句提供了根据指定条件选择执行哪组语句的灵活性,因此程序中语句的执行顺序因数值 的不同而不同。与此相反, goto 语句却很死板。该语句允许无条件转移到指定的程序语句。位于 转移目的地的语句必须用某个语句标签来标识,这种标签也是按照定义变量名的规则来定义的标 识符。语句标签后面应该跟一个冒号,还应该放在需要标记的语句前面。下面是一条被标记语句 的示例。 myLabel: cout << "myLabel branch has been activated" << endl; 该语句的标签是 myLabel,无条件转移到这条语句的语句如下所示: goto myLabel; 第 3 章 判断和循环 95 只要可能,就应该避免在程序中使用 goto 语句。这些 goto 语句往往导致错综复杂的、难以理 解的代码。 3.2 重复执行语句块 对大多数应用程序而言,重复一组语句的功能是基本要求。如果没有这种功能,则公司每当雇 用一名新员工时就需要修改工资计算程序,每当我们想要玩自己喜欢的游戏时就需要重新加载。因 此,下面首先介绍一下循环的工作原理。 3.2.1 循环的概念 循环即重复执行一个语句序列,直到特定的条件为 true 或 false 为止。实际上,我们用目前所学 过的 C++语句就能编写出循环,需要的只是 if 和令人畏惧的 goto 而已。看下面的示例: // Ex3_07.cpp // Creating a loop with an if and a goto #include using std::cin; using std::cout; using std::endl; int main() { int i(1), sum(0); const int max(10); loop: sum += i; // Add current value of i to sum if(++i <= max) goto loop; // Go back to loop until i = 11 cout << endl << "sum = " << sum << endl << "i = " << i << endl; return 0; } 本示例是累加整数 1~10 的和。首次执行该语句序列时, i 的初始值是 1,该变量与最初是 0 的 sum 相加。在 if 语句中, i 递增为 2,但只要它小于等于 max,就无条件转移到 loop,然后 i 的值(现 在是 2)再次与 sum 相加。每次使 i 递增并与 sum 相加的动作重复执行,直到最后在 if 语句中 i 递增到 理论上,程序中的 goto 语句不是必需的,因为总有替代 goto 语句的方法,所以 某些程序员声称应该永远不使用 goto 语句。笔者不同意这样的极端观点。goto 语句毕 竟是一条合法语句,而且在有些场合下使用起来很方便,例如必须从一个深度嵌套的 循环(下一节将介绍)中退出时。但是,笔者还是建议仅当能够看到明显优于其他可用 选择时才使用 goto;否则,可能得到难以理解、更难以维护的、错综复杂且容易出错 的代码。 Visual C++ 2012 入门经典(第 6 版) 96 11 时,才不再重复这一过程。如果运行该示例,则得到下面的输出。 sum = 55 i = 11 本示例非常清楚地展示了循环的工作过程,但使用了 goto 语句,还在程序中引入一个标签,这 两者都是我们应该尽可能避免的。使用下面这条专门用于编写循环的 for 语句,可以实现相同的功 能,甚至更多。 试一试:使用 for 循环 可以使用 for 循环来重写上一个示例。 // Ex3_08.cpp // Summing integers with a for loop #include using std::cin; using std::cout; using std::endl; int main() { int i(1), sum(0); const int max(10); for(i = 1; i <= max; i++) // Loop specifi cation sum += i; // Loop statement cout << endl << "sum = " << sum << endl << "i = " << i << endl; return 0; } 示例说明 如果编译并运行该程序,那么将得到与前面的示例完全相同的输出,但这里的代码更加简单。 决定循环操作的条件位于关键字 for 后面的圆括号中。该圆括号内共有 3 个以分号隔开的表达式: ● 第一个表达式最初执行一次, 以设定循环的初始条件,在本示例中,该表达式将 i 设置为 1。 ● 第二个是逻辑表达式,它决定是否应该继续执行循环语句 (或语句块 )。如果第二个表达式为 true,则继续执行循环;如果是 false,则结束循环,然后执行循环后面的语句。在本示例中, 只要 i 小于等于 max,就一直执行下面的循环语句。 ● 在循环语句 (或语句块)执行之后,计算机将求出第三个表达式的值,在本示例中,每次循环 都使 i 加 1。在计算该表达式之后,第二个表达式再次被计算,以确定循环是否应该继续。 实际上,该循环与 Ex3_07.cpp 中的版本不完全相同。在这两个程序中都将 max 的值设置为 0, 然后再次运行两个程序,就可以证明这一点。我们将发现在 Ex3_07.cpp 中 sum 的值是 1,在 Ex3_08.cpp 中 sum 的值是 0,i 的值也不同。原因是 if 版本的程序总是至少执行一次循环,因为我 们直到最后才检查测试条件。for 循环却不是这样,因为测试条件实际上是在最开始检查的。 for 循环的通用形式如下: 第 3 章 判断和循环 97 for(initializing_expression; test_expression; increment_expression) loop_statement; 当然, loop_statement 可以是一条语句,也可以是大括号之间的语句块。执行 for 循环的事件序 列如图 3-3 所示。 执行 initializing_expression test_expression 是否为 true? 否 是 执行 loop_statement 执行 increment_expression 继续执行下一条语句 图 3-3 for 循环的逻辑 控制 for 循环的表达式是非常灵活的。甚至可以为每个控制表达式编写两个或更多以逗号运算 符分开的表达式。该特点在 for 循环的用途方面为我们提供了巨大的空间。 3.2.2 for 循环的变体 大多数时候, for 循环中的表达式都是以相当标准的方式使用的:第一个表达式用于初始化一个 或多个循环计数器,第二个表达式用于测试循环是否应该继续,第三个表达式递增或递减一个或多 个循环计数器。没人强迫我们以这种方式使用表达式,但是,可能有相当多的变体。 较有数学头脑的人知道,不使用循环,就可以计算出前 n 个整数之和。从 1 到 n 的整数之和可以用表达式 n(n+1)/2 来计算。但使用这个表达式不能学习到循环的知识。 Visual C++ 2012 入门经典(第 6 版) 98 for 循环中的初始化表达式也可以包括循环变量的声明。 在前面的示例中, 还可以这样编写循环, 即在第一个控制表达式中包括循环计数器 i 的声明。 for(int i = 1; i <= max; i++) // Loop specification sum += i; // Loop statement 当然,可能需要省略该程序中原来对 i 的声明。如果对上一个示例进行这样的修改,则将发现 该程序现在不能编译。因为循环变量 i 在循环之后不再存在,所以不能在输出语句中引用该变量。 循环的作用域从 for 表达式开始,一直延伸到循环体的结束。循环体可以是大括号之间的代码块, 也可以是一条语句。现在计数器 i 是在循环作用域内声明的,所以不能在输出语句中引用该变量, 因为输出语句在循环作用域的外部。如果需要在执行循环之后使用计数器的值,则必须在循环作用 域的外部声明计数器变量。 可以完全省略循环中的初始化表达式。因为 i 具有初始值 1,所以可以将该循环写成下面的形式: for(; i <= max; i++) // Loop specification sum += i; // Loop statement 该循环仍然需要分开初始化表达式与测试表达式的分号。事实上,无论是否省略任何或全部控 制表达式,两个分号都不能省略。如果省略第一个分号,编译器将无法判断省略了哪个表达式,或 者说遗漏了哪个分号。 循环语句可以为空。例如,可以将上一个示例中 for 循环的循环语句放入递增表达式内部,这 种情况下该循环就成为: for(; i <= max; sum += i++); // The whole loop 为了指示循环语句为空,仍然需要在圆括号后面加上分号。如果省略该分号,则紧跟这行代码 的语句将被解释为循环语句。有时,空循环语句会写在单独一行上,如下所示。 for(; i <= max; sum += i++) // The whole loop ; 试一试:使用多个计数器 为了在 for 循环中包括多个计数器,可以使用逗号运算符。在下面的程序中,将看到这种用法。 // Ex3_09.cpp // Using multiple counters to show powers of 2 #include #include using std::cin; using std::cout; using std::endl; using std::setw; int main() { const int max(10); for(long i = 0L, power = 1L; i <= max; i++, power += power) cout << endl 第 3 章 判断和循环 99 << setw(10) << i << setw(10) << power; // Loop statement cout << endl; return 0; } 示例说明 在 for 循环的初始化部分创建并初始化两个变量,然后在递增部分将它们递增。可以创建任意 多的变量,只要它们具有相同的类型即可。 也可以在第二个表达式中指定多个以逗号分开的条件,该表达式是 for 循环的测试部分,决定 着循环是否应该继续,但它一般没有用,因为只有最右边的条件影响循环结束的时间。 变量 i 每递增一次,变量 power 的值就通过与自身相加而倍增。这样将产生我们所期待的 2 的 幂值,因此程序产生下面的输出。 0 1 1 2 2 4 3 8 4 16 5 32 6 64 7 128 8 256 9 512 10 1024 在第 2 章介绍的 setw()操作符用来精确地对齐输出。我们已经嵌入了 iomanip 头文件,还为 std 名称空间中的 setw 名称添加了 using 声明,因此可以不加限定名来使用 setw()。 试一试:无穷 for 循环 如果省略为 for 循环指定测试条件的第二个控制表达式, 则该表达式的值将被假定为 true,因此 循环将无限期继续,除非提供从循环中退出的其他手段。事实上如果愿意,则可以省略 for 后面圆 括号中的所有表达式。这样做可能看起来没有用处,但实际上恰恰相反。我们会经常遇到需要多次 执行某个循环的情况,但预先不知道需要的循环次数。请看下面的程序: // Ex3_10.cpp // Using an infinite for loop to compute an average #include using std::cin; using std::cout; using std::endl; int main() { double value(0.0); // Value entered stored here double sum(0.0); // Total of values accumulated here int i(0); // Count of number of values char indicator('n'); // Continue or not? for(;;) // Indefinite loop Visual C++ 2012 入门经典(第 6 版) 100 { cout << endl << "Enter a value: "; cin >> value; // Read a value ++i; // Increment count sum += value; // Add current input to total cout << endl << "Do you want to enter another value (enter y or n)? "; cin >> indicator; // Read indicator if (('n' == indicator) || ('N' == indicator)) break; // Exit from loop } cout << endl << "The average of the " << i << " values you entered is " << sum/i << "." << endl; return 0; } 示例说明 该程序计算任意数量的值的平均值。在输入每个值之后,需要输入一个字符 y 或 n,来指示是 否想输入另一个值。执行该示例的典型输出如下: Enter a value: 10 Do you want to enter another value (enter n to end)? y Enter a value: 20 Do you want to enter another value (enter n to end)? y Enter a value: 30 Do you want to enter another value (enter n to end)? n The average of the 3 values you entered is 20. 在声明并初始化要使用的变量之后,进入一个未指定任何表达式的 for 循环,因此没有关于循 环结束条件的规定。紧跟其后的语句块是重复执行的循环主体。 循环块完成 3 个基本动作: ● 读取某个值 ● 将从 cin 读取的值与 sum 相加 ● 检查用户是否想继续输入值 循环块中的第一个动作是提示用户输入一个值,然后将其读入变量 value。输入的值与 sum 相 加,同时计数器 i 递增。在累加 sum 中的值之后,程序询问用户是否想输入另一个值,并提示用户 输入 y,如果已经结束就输入 n。输入的字符存储在变量 indicator 中,该变量在 if 语句中用于测试 输入的字符是否是 n 或 N。如果既不是 n 也不是 N,则循环继续;否则执行 break。循环中 break 的 作用类似于它在 switch 语句中的作用。在这种情况下, break 将控制权传递给循环块的右大括号后 面的语句,使循环立即结束。 第 3 章 判断和循环 101 最后, 输出输入值的个数以及将 sum 除以 i 后得到的平均值。 当然, 计算之前 i 被升级为 double 类型,参见第 2 章关于类型强制转换的讨论。 1. 使用 continue 语句 continue 语句可以简明地写成下面的形式: continue; 执行循环内部的 continue 语句将跳过循环体中其他剩余的语句,而立即启动下一次循环迭代。 可以用下面的代码来示范 continue 语句的作用: #include using std::cin; using std::cout; using std::endl; int main() { int value(0), product(1); for(int i = 1; i <= 10; i++) { cout << "Enter an integer: "; cin >> value; if(0 == value) // If value is zero continue; // skip to next iteration product *= value; } cout << "Product (ignoring zeros): " << product << endl; return 0; // Exit from loop } 该循环读取 10 个数值, 目的是得到输入值的乘积。 if 语句检查每个输入值, 如果是 0,则 continue 语句跳到下一次迭代。这样,即使某个输入值为 0,也不会得到等于 0 的乘积。显然,如果最后一 次迭代时输入值为 0,则该循环将结束。当然还有其他一些方法可以得到相同的结果,但 continue 语句提供了非常有用的功能,特别是在需要从循环体的不同位置跳到当前迭代结束处这样的复杂循 环中。 在 for 循环的逻辑中,break 和 continue 语句的作用如图 3-4 所示。 显然,在实际情况中将在某些条件测试逻辑中使用 break 和 continue 语句,以确定何时应该退 出循环,或者何时应该跳过循环的迭代。还可以在本章稍后讨论的其他类型循环中使用 break 和 continue 语句,它们还是以完全相同的方式工作。 Visual C++ 2012 入门经典(第 6 版) 102 执行 initializing_expression test_expression 是否为 true? continue; break; 执行 increment_expression 继续执行下一条语句 直接退出循环 否 是 跳过循环中的随后的 语句 图 3-4 循环中的 break 和 continue 语句 试一试:在循环中使用其他数据类型 迄今为止,我们仅仅使用过整数来记录循环的迭代次数。在使用何种变量类型来记录循环迭代 次数方面,绝对不会受到任何限制。请看下面的示例: // Ex3_11.cpp // Display ASCII codes for alphabetic characters #include #include using std::cout; using std::endl; using std::hex; using std::dec; using std::setw; int main() { for(char capital = 'A', small = 'a'; capital <= 'Z'; capital++, small++) 第 3 章 判断和循环 103 cout << endl << "\t" << capital // Output capital as a character << hex << setw(10) << static_cast (capital) // and as hexadecimal << dec << setw(10) << static_cast (capital) // and as decimal << " " << small // Output small as a character << hex << setw(10) << static_cast (small) // and as hexadecimal << dec << setw(10) << static_cast (small); // and as decimal cout << endl; return 0; } 示例说明 我们给几个新的操作符名称提供了 using 声明,这些操作符在本程序中用来影响输出的显示方式。 本示例中的循环由 char 类型的变量 capital 控制,我们在初始化表达式中声明了该变量及 small 变量。在循环的第三个控制表达式中,还使这两个变量递增,这样 capital 的值将从 'A'变化到 'Z',而 small 的值将相应地从'a'变化到'z'。 该循环只包含一条输出语句,但此语句分 7 行写完,第一行是: cout << endl 这一行代码使结果在屏幕上换行输出。 接下来的 3 行代码如下: << "\t" << capital // Output capital as a character << hex << setw(10) << static_cast (capital) // and as hexadecimal << dec << setw(10) << static_cast (capital) // and as decimal 每次迭代时,在输出制表符之后, capital 的值将以字符、十六进制数和十进制数 3 种形式显示 3 次。 当将 hex 操作符插入 cout 流时,将使后面的整数值以十六进制数形式显示,而不是以默认的十 进制表示法显示。因此,capital 的第二次输出是其字符的十六进制表示法。 然后,将 dec 操作符插入 cout 流,从而使后面的数值再次以十进制形式输出。默认情况下, char 类型的变量被输出流解释为字符,而不是数值。使用第 2 章介绍的 static_cast< >()运算符,将 char 类型变量 capital 的值强制转换为 int 类型,就可以使该变量以数值形式输出。 输出语句的后 3 行代码以类似的方式输出 small 的值: << " " << small // Output small as a character << hex << setw(10) << static_cast (small) // and as hexadecimal << dec << setw(10) << static_cast (small); // and as decimal 因此,该程序产生下面的输出: A 41 65 a 61 97 B 42 66 b 62 98 C 43 67 c 63 99 D 44 68 d 64 100 E 45 69 e 65 101 F 46 70 f 66 102 G 47 71 g 67 103 H 48 72 h 68 104 Visual C++ 2012 入门经典(第 6 版) 104 I 49 73 i 69 105 J 4a 74 j 6a 106 K 4b 75 k 6b 107 L 4c 76 l 6c 108 M 4d 77 m 6d 109 N 4e 78 n 6e 110 O 4f 79 o 6f 111 P 50 80 p 70 112 Q 51 81 q 71 113 R 52 82 r 72 114 S 53 83 s 73 115 T 54 84 t 74 116 U 55 85 u 75 117 V 56 86 v 76 118 W 57 87 w 77 119 X 58 88 x 78 120 Y 59 89 y 79 121 Z 5a 90 z 7a 122 2. 浮点循环计数器 还可以使用浮点数作为循环计数器。下面的 for 循环示例就使用了这种计数器: double a(0.3), b(2.5); for(double x = 0.0; x <= 2.0; x += 0.25) cout << "\n\tx = " << x << "\ta*x + b = " << a*x + b; 该代码段计算 a*x+b 的值,x 的值以 0.25 为步长从 0.0 增加到 2.0,但我们需要注意何时在循环 中使用浮点计数器。许多小数值不能以二进制浮点形式精确表示,因此累积数值时可能产生误差。 这意味着 for 循环的结束不应该取决于浮点循环计数器达到某个精确值。例如,下面这个设计拙劣 的循环将永远不会结束。 for(double x = 0.0 ; x != 1.0 ; x += 0.1) cout << x; 该循环的意图是随着 x 从 0.0 变化到 1.0,输出该变量的值,但 0.1 不能精确表示成二进制浮点 数,这样 x 的值就永远不会精确等于 1。因此,第二个循环控制表达式始终为 false,这样该循环将 无限继续下去。 3.2.3 while 循环 C++中第二种循环是 while 循环。 for 循环主要用来以指定的迭代次数重复执行某条语句或某个 语句块,而 while 循环用来在指定条件为 true 时执行某条语句或某个语句块。 while 循环的通用形式 如下: 很容易看出为什么一些十进制的小数值不能精确地表示为二进制值。在二进制的 小数部分,小数点右边的数字等价于 1/2、1/4、1/8/、1/16 等的十进制小数部分。因此 十进制的小数部分总是上述一个或多个分数之和。1/3 或 1/10 等这样具有古怪分母或 古怪分子的十进制小数,永远不能精确地表示为分母为偶数的分数之和。 第 3 章 判断和循环 105 while(condition) loop_statement; 只要 condition 表达式的值为 true,就重复执行 loop_statement。当条件变为 false 之后,程序继 续执行循环后面的语句。和往常一样,可以用大括号之间的语句块来取代单个的 loop_statement。 while 循环的逻辑可以用图 3-5 来表示。 否 是 循环条件 是否为 true? loop_statement 继续执行下一条语句 图 3-5 试一试:使用 while 循环 可以使用 while 循环来重写前面计算平均值的示例(Ex3_10.cpp)。 // Ex3_12.cpp // Using a while loop to compute an average #include using std::cin; using std::cout; using std::endl; int main() { double value(0.0); // Value entered stored here double sum(0.0); // Total of values accumulated here int i(0); // Count of number of values char indicator('y'); // Continue or not? while('y' == indicator ) // Loop as long as y is entered { cout << endl << "Enter a value: "; cin >> value; // Read a value ++i; // Increment count sum += value; // Add current input to total cout << endl Visual C++ 2012 入门经典(第 6 版) 106 << "Do you want to enter another value (enter y or n)? "; cin >> indicator; // Read indicator } cout << endl << "The average of the " << i << " values you entered is " << sum/i << "." << endl; return 0; } 示例说明 对于相同的输入,上面的程序产生与以前相同的输出。该程序更新了一条语句,另外添加了一 条语句。上面的代码突出显示了这两条语句。 while 语句代替了 for 循环语句,同时删除了在 if 语句 中对 indicator 的测试,因为该功能现在由 while 条件完成。我们必须用 y 代替先前的 n 来初始化 indicator,否则 while 循环将立即终止。只要 while 中的条件返回 true,该循环就继续。 可以将任何结果为 true 或 false 的表达式用作 while 循环的条件。如果上面的循环条件扩展为允 许输入 'Y'及'y'来使循环继续,则该示例将成为更好的一个程序。我们可以将 while 条件修改成 下面的样子,以实现这样的功能。 while(('y' == indicator) || ('Y' == indicator)) 使用始终为 true 的条件,还可以建立无限期执行的 while 循环。这样的循环可以写成下面的 形式: while(true) { ... } 也可以用整数值 1 作为循环控制表达式, 1 将转换为 bool 值 true。当然,这里的要求与无穷 for 循环的情形相同:必须在循环块内提供一种退出循环的方法。第 4 章将介绍其他使用 while 循环的 方式。 3.2.4 do-while 循环 do-while 循环与 while 循环的类似之处是只要指定的循环条件为 true,循环就继续。主要区别是 do-while 循环在循环结束时才检查循环条件,这与 while 循环和 for 循环相反,后两者在循环开始时 检查循环条件。因此,do-while 循环语句总是至少执行一次。do-while 循环的通用形式如下: do { loop_statements; }while(condition); do-while 循环的逻辑如图 3-6 所示。 第 3 章 判断和循环 107 循环条件 是否为 true?是 否 继续执行下一条语句 { loop_statement; } do-while 循环逻辑 图 3-6 可以用 do-while 循环代替前面程序中的 while 循环,以计算平均值。 do { cout << endl << "Enter a value: "; cin >> value; // Read a value ++i; // Increment count sum += value; // Add current input to total cout << "Do you want to enter another value (enter y or n)?"; cin >> indicator; // Read indicator } while(('y' == indicator) || ('Y' == indicator)); do-while 循环的正确运行不依赖 indicator 的初始值设定,除此之外,这两种循环之间没有区别。 只要是至少想输入一个值—— 就我们所讨论的计算问题而言这是合理的,do-while 循环就更合适。 3.2.5 基于范围的循环 这里介绍这种循环,这样就把可用的所有循环都放在一起讨论。基于范围的 for 循环可以用非 常简单的方式迭代集合中的每一项。我们还没有遇到可以使用这种循环的集合,但下一章在介绍数 组时,就可以使用这种循环。那时会详细讨论如何使用基于范围的 for 循环,在第 10 章学习可以使 用基于范围的 for 循环的其他集合类型。 3.2.6 嵌套的循环 可以在一个循环内部嵌套另一个循环。在第 4 章,嵌套循环的常见用途将更加明显—— 通常用 来重复不同等级的动作。例如,首先计算某班学生的总分,然后对全校各班重复该过程。 试一试:嵌套的循环 通过计算一个简单公式的值,可以看出嵌套循环的作用。某个整数的阶乘是从 1 到该整数所有 整数的乘积,因此 3 的阶乘是 1×2×3,结果是 6。下面的程序计算所输入的整数的阶乘 (直到不再 输入新的整数为止): Visual C++ 2012 入门经典(第 6 版) 108 // Ex3_13.cpp // Demonstrating nested loops to compute factorials #include using std::cin; using std::cout; using std::endl; int main() { char indicator('n'); long value(0L), factorial(0L); do { cout << endl << "Enter an integer value: "; cin >> value; factorial = 1L; for(long i = 2L; i <= value; i++) factorial *= i; cout << "Factorial " << value << " is " << factorial; cout << endl << "Do you want to enter another value (y or n)? "; cin >> indicator; } while(('y' == indicator) || ('Y' == indicator)); return 0; } 如果编译并执行该示例,则产生的典型输出如下: Enter an integer value: 5 Factorial 5 is 120 Do you want to enter another value (y or n)? y Enter an integer value: 10 Factorial 10 is 3628800 Do you want to enter another value (y or n)? y Enter an integer value: 13 Factorial 13 is 1932053504 Do you want to enter another value (y or n)? y Enter an integer value: 22 Factorial 22 is -522715136 Do you want to enter another value (y or n)? n 示例说明 阶乘值以非常快的速度增长。事实上, 12 是能够使该示例产生正确结果的最大输入值。 13 的阶 乘实际上是 6 227 020 800,不是该程序算出的 1 932 053 504。如果使用更大的输入值运行该程序,则 变量 factorial 所存储的结果将丢失前导数字,也可能得到负的阶乘值,就像前面计算 22 的阶乘时所 发生的情形一样。 这种情况不会引起任何错误消息,因此最重要的是确保程序中所处理的值不超出其数据类型限 第 3 章 判断和循环 109 定的范围。我们还需要考虑不正确输入的影响。这种悄悄发生的错误可能非常难以发现。 两个嵌套循环的外部是 do-while 循环,它控制着程序结束的时间。只要在提示时继续输入 y 或 Y,该程序就继续计算阶乘值。输入整数的阶乘是在内部 for 循环中计算的。该循环执行 value-1 次, 将变量 factorial(初始值为 1)乘以从 2 到 value 的连续整数。 试一试:另一个嵌套的循环 嵌套循环可能使人感到迷惑,因此我们再看一个示例。该程序可以生成一个给定大小的乘法表。 // Ex3_14.cpp // Using nested loops to generate a multiplication table #include #include using std::cout; using std::endl; using std::setw; int main() { const int size(12); // Size of table int i(0), j(0); // Loop counters cout << endl // Output table title << size << " by " << size << " Multiplication Table" << endl << endl; cout << endl << " |"; for(i = 1; i <= size; i++) // Loop to output column headings cout << setw(3) << i << " "; cout << endl; // Newline for underlines for(i = 0; i <= size; i++) cout << "_____"; // Underline each heading for(i = 1; i <= size; i++) // Outer loop for rows { cout << endl << setw(3) << i << " |"; // Output row label for(j = 1; j <= size; j++) // Inner loop for the rest of the row cout << setw(3) << i*j << " "; // End of inner loop } // End of outer loop cout << endl; return 0; } 该示例的输出如下: | 1 2 3 4 5 6 7 8 9 10 11 12 _________________________________________________________________ 1 | 1 2 3 4 5 6 7 8 9 10 11 12 2 | 2 4 6 8 10 12 14 16 18 20 22 24 3 | 3 6 9 12 15 18 21 24 27 30 33 36 4 | 4 8 12 16 20 24 28 32 36 40 44 48 Visual C++ 2012 入门经典(第 6 版) 110 5 | 5 10 15 20 25 30 35 40 45 50 55 60 6 | 6 12 18 24 30 36 42 48 54 60 66 72 7 | 7 14 21 28 35 42 49 56 63 70 77 84 8 | 8 16 24 32 40 48 56 64 72 80 88 96 9 | 9 18 27 36 45 54 63 72 81 90 99 108 10 | 10 20 30 40 50 60 70 80 90 100 110 120 11 | 11 22 33 44 55 66 77 88 99 110 121 132 12 | 12 24 36 48 60 72 84 96 108 120 132 144 示例说明 表标题是程序中第一条输出语句产生的。 下一条输出语句与后面跟着的循环共同生成了列标题。 每列是 5 个字符宽, 因此标题值显示在 setw(3)操作符指定的 3 字符宽的字段中, 后面跟着两个空格。 该循环前面的输出语句在包含行标题的第一列上面输出 4 个空格和一条竖线。然后,一串下划线字 符显示在列标题的下面。 嵌套循环生成主表的内容。外部循环为每一行重复一次,因此 i 是行号。输出语句 cout << endl << setw(3) << i << " |"; // Output row label 首先转到下一行,然后在 3 字符宽的字段中输出行标题 i 的值,后面是一个空格和一条竖线。 下面的内部循环生成各行的数值: for(j = 1; j <= size; j++) // Inner loop for the rest of the row cout << setw(3) << i*j << " "; // End of inner loop 该循环输出 i*j 的值,这些值是当前行值 i 与依次从 1 变化到 size 的各个列值 j 的乘积。因此, 对于外部循环的每次迭代而言, 内部循环执行 size 次迭代。得到的乘积以与列标题相同的方式定位。 当外部循环完成时,执行 return 语句以结束该程序。 3.3 小结 本章学习了在 C++程序中进行判断的所有基本机制。比较值和改变程序执行过程的能力使计算 机区别于简单的计算器。我们必须自如地运用已经讨论过的所有判断语句,因为会经常使用它们。 我们也已经讨论了重复执行一组语句的所有功能。循环是在编写每个程序中都需要使用的基本编程 技术。在 C++中 for 循环使用得最多,其次是 while 循环。 3.4 练习 1. 编写一个程序,要求能够读取 cin 中的数字,然后求出这些数的总和,当输入 0 时停止程序。 分别用 while、do-while 和 for 循环构建此程序。 2. 编写一个程序,要求能够读取从键盘输入的字符,然后计算元音字母的个数。遇到 Q 或 q 时停止计数。使用一个无穷循环组合来读取字符,并用一个 switch 语句计数字符。 3. 编写一个程序,逐列打印出 2~12 的乘法表。 第 3 章 判断和循环 111 4. 假设要在程序中基于“文件类型”和“打开方式”这两个属性来设置“文件打开模式”变量。 文件类型可以是文本或二进制,打开方式可以是读、写或添加数据。使用按位运算符 (&和|)和一组 标志,设计一种方法使一个整型变量能设置成这两种属性的任意组合。编写一个程序来设置这样的 变量,然后解码该变量,为所有可能的属性组合打印出该变量的设置。 3.5 本章主要内容 本章主要内容如表 3-2 所示。 表 3-2 主 题 概 念 关系运算符 关系运算符可以组合逻辑值或结果为逻辑值的表达式,它们会生成 bool 值 true 或 false,作 为可以在 if 语句中使用的结果 基于数值进行判断 可以基于返回非 bool 值的条件进行判断。当测试条件时,任何非零值都被强制转换为 true, 零值被强制转换为 false 判断语句 C++中基本的判断功能是由 if 语句提供的。switch 语句和条件运算符提供了更大的灵活性 循环语句 重复执行语句块有 4 种基本方法:for 循环、while 循环、do-while 循环和基于范围的 for 循 环。for 循环允许循环重复执行给定的次数。while 循环使循环在给定条件返回 true 时继续。 do-while 循环至少执行一次循环,然后使循环在给定条件返回 true 时继续。基于范围的 for 循环允许迭代集合中的所有项 嵌套循环 任何循环都可以嵌套在其他循环内部 continue 关键字 关键字 continue 允许跳过循环中当前迭代的剩余语句,而直接进入下一次迭代 break 关键字 关键字 break 导致循环立即退出。break 如果位于 case 语句的最后,还会从 switch 中退出 深入理解类 本章要点 ● 类析构函数的概念,需要析构函数的情形和原因 ● 如何实现类析构函数 ● 如何在空闲存储器中创建类的数据成员,不再需要时如何将它们删除 ● 何时必须编写类的复制构造函数 ● 联合的概念和用法 ● 如何使类的对象使用+或*这样的运算符 ● 如何使用 rvalue 引用形参来避免不必要地复制类对象 ● 类模板的概念,定义和使用类模板的方法 ● 完美转发的概念和实现方式 ● 如何使用标准的 string 类进行字符串操作 8.1 类析构函数 虽然本节标题是析构函数,但还与动态内存分配有关。在空闲存储器中为类成员分配内存时, 除必须利用构造函数以外,还必须利用析构函数,另外,使用动态分配的类成员还要求编写自定义 复制构造函数,本章后面将介绍这一点。 8.1.1 析构函数的概念 析构函数用于销毁不再需要或超出其作用域的对象。当对象超出其作用域时,程序将自动调用 类的析构函数。销毁对象需要释放该对象的数据成员(即使没有类对象存在时也将继续存在的静态成 员除外)占用的内存。类的析构函数是与类同名的成员函数,只是类名前需要加个波形符(~)。类析 构函数不返回任何值,也没有形参。就 CBox 类来说,其析构函数的原型如下: ~CBox(); // Class destructor prototype 8 第 章 Visual C++ 2012 入门经典(第 6 版) 278 因为析构函数有特定的名称,没有任何形参,所以一个类只能有一个析构函数。 8.1.2 默认的析构函数 迄今为止使用过的所有对象都是由类的默认析构函数自动销毁的。如果不能定义自己的类析构 函数,编译器就总是自动生成默认的析构函数。默认的析构函数不能删除在空闲存储器中分配的对 象或对象成员。如果类成员占用的空间是在构造函数中动态分配的,就必须自定义析构函数,然后 显式使用 delete 操作符来释放构造函数使用 new 操作符分配的内存,就像销毁普通变量一样。我们 需要在编写析构函数方面实践一下,因此下面就来试一试。 试一试:简单的析构函数 为了解何时调用类的析构函数,可以在 CBox 类中添加析构函数。下面是本示例的代码: // Ex8_01.cpp // Class with an explicit destructor #include using std::cout; using std::endl; class CBox // Class definition at global scope { public: // Destructor definition ~CBox() { cout << "Destructor called." << endl; } // Constructor definition explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0): m_Length(lv), m_Width(wv), m_Height(hv) { cout << endl << "Constructor called."; } // Function to calculate the volume of a box double Volume() const { return m_Length*m_Width*m_Height; } // Function to compare two boxes which returns true // if the first is greater that the second, and false otherwise bool compare(CBox* pBox) const 注意:给析构函数指定返回值或形参是错误的。 注意:第 10 章将学习智能指针,它能自动删除空闲存储器中不再需要的内存, 所 以在许多情况下不再需要析构函数。 第 8 章 深入理解类 279 { return this->Volume() > pBox->Volume(); } private: double m_Length; // Length of a box in inches double m_Width; // Width of a box in inches double m_Height; // Height of a box in inches }; // Function to demonstrate the CBox class destructor in action int main() { CBox boxes[5]; // Array of CBox objects declared CBox cigar(8.0, 5.0, 1.0); // Declare cigar box CBox match(2.2, 1.1, 0.5); // Declare match box CBox* pB1(&cigar); // Initialize pointer to cigar object address CBox* pB2(0); // Pointer to CBox initialized to null cout << endl << "Volume of cigar is " << pB1->Volume(); // Volume of obj. pointed to pB2 = boxes; // Set to address of array boxes[2] = match; // Set 3rd element to match cout << endl << "Volume of boxes[2] is " << (pB2 + 2)->Volume(); // Now access thru pointer cout << endl; return 0; } 示例说明 CBox 类的析构函数仅显示一条宣称“析构函数被调用”的消息。该示例的输出如下: Constructor called. Constructor called. Constructor called. Constructor called. Constructor called. Constructor called. Constructor called. Volume of cigar is 40 Volume of boxes[2] is 1.21 Destructor called. Destructor called. Destructor called. Destructor called. Destructor called. Destructor called. Destructor called. 在程序结束时,每个对象都需要调用一次析构函数。每出现一次构造函数的调用,就有一次匹 配的析构函数调用。在这里,不需要显式调用析构函数。当某个类对象需要销毁时,编译器将自动 安排调用该类的析构函数。本示例中,析构函数的调用发生在 main()函数的执行结束之后,因此在 Visual C++ 2012 入门经典(第 6 版) 280 main()函数安全终止之后因析构函数存在错误而使程序崩溃也是非常有可能的。 8.1.3 析构函数与动态内存分配 程序中经常需要为类的数据成员动态分配内存。可以在构造函数中使用 new 操作符来为对象成 员分配空间。在这种情况下,必须提供适当的析构函数,在不再需要该对象时释放内存(或者使用智 能指针,参见第 10 章)。下面首先定义一个简单的类,以进行这样的练习。 假设定义一个类,其中每个对象都是描述性的消息(如文本字符串)。这个类应该尽可能高效地 利用内存,因此不能将数据成员定义成足以容纳所需最大长度字符串的 char 数组。应该在创建对象 时在空闲存储器中为消息分配内存。类定义如下所示: //Listing 08_02_1 class CMessage { private: char* pmessage; // Pointer to object text string public: // Function to display a message void ShowIt() const { cout << endl << pmessage; } // Constructor definition CMessage(const char* text = "Default message") { pmessage = new char[strlen(text) + 1]; // Allocate space for text strcpy_s(pmessage, strlen(text) + 1, text); // Copy text to new memory } ~CMessage(); // Destructor prototype }; 该类仅定义了一个数据成员 pmessage,该成员是一个指向文本串的指针。这是在类的 private 部分定义的,因此不能从类外部访问。 在 public 部分,ShowIt()函数为 CMessage 对象输出字符串。还定义了构造函数,并添加了该类 的析构函数~CMessage()的原型,我们很快就会讨论它。 类的构造函数要求实参是字符串,但如果不传递任何实参,则它使用为形参指定的默认字符串。 构造函数使用库函数 strlen(),获得字符串实参的长度(不包括终止空字符NULL)。为了使构造函数能 够使用 strlen()库函数,程序中必须有嵌入 cstring 头文件的#include 语句。 通过将 strlen()函数返回的数值加 1,构造函数即可求出在空闲存储器中存储该字符串所需要 注意: cstring 头文件声明了来自 C 运行库中的函数,它们不在名称空间中定义, 所以函数名可以不使用名称空间名来限定。因为这些函数也是 C++标准库的一部分, 所以这些函数名也在 std 名称空间中定义。因此可以用 std 限定它们。 第 8 章 深入理解类 281 的内存字节数。 之后,我们使用也是在 cstring 头文件中声明的 strcpy_s()库函数,将给构造函数提供的字符串实参 复制到为字符串分配的内存中。strcpy_s()函数将第三个指针实参指定的字符串复制到第一个指针实参 包含的地址中。第二个实参指定目标位置的长度。 现在需要编写类的析构函数,以释放为消息分配的内存。如果不给该类提供析构函数,那么程 序将无法释放为类对象分配的内存。如果按照现状在程序中使用这个类创建大量的 CMessage 对象, 那么将逐渐耗尽空闲存储器,直至程序失败为止。在不容易发现此类问题的环境中,却很容易出现 上述现象。例如,如果要在一个被程序多次调用的函数中创建临时的 CMessage 对象,则可能认为 在从函数返回时销毁该对象。当然,这种看法是正确的,只是没有释放空闲存储器中的内存。因此, 每调用一次该函数,就有更多的空闲存储器内存被抛弃的 CMessage 对象占用。 CMessage 类析构函数的代码如下所示: // Listing 08_02_2 // Destructor to free memory allocated by new CMessage::~CMessage() { cout << "Destructor called." // Just to track what happens << endl; delete[] pmessage; // Free memory assigned to pointer } 因为是在类定义外部定义析构函数,所以必须以类名 CMessage 限定析构函数名。析构函数的 作用只是显示一条消息,告诉我们所发生的事情,然后使用 delete 操作符释放 pmessage 成员指向的 内存。注意,delete 后面的方括号是必需的,因为我们是在删除数组(char 类型)。 试一试:使用消息类 通过下面这个小示例,可以练习 CMessage 类的用法。 // Ex8_02.cpp // Using a destructor to free memory #include // For stream I/O #include // For strlen() and strcpy() using std::cout; using std::endl; // Put the CMessage class definition here (Listing 08_02_1) // Put the destructor definition here (Listing 08_02_2) int main() { // Declare object 注意:当然,如果内存分配失败,则将抛出异常并且终止程序。如果我们希望管 理此类故障,以便 程序顺利运行,那么应该在构造函数代码中捕获此类异常 (见第 6 章关于处理内存不足状况的信息)。 Visual C++ 2012 入门经典(第 6 版) 282 CMessage motto("A miss is as good as a mile."); // Dynamic object CMessage* pM(new CMessage("A cat can look at a queen.")); motto.ShowIt(); // Display 1st message pM->ShowIt(); // Display 2nd message cout << endl; delete pM; // Manually delete object created with new return 0; } 记着用上一节的 CMessage 类和析构函数定义的代码代替此处代码中的注释,如果没有它们, 那么将不能编译该程序(下载的源代码中含有本示例的所有代码)。 示例说明 在 main()的开始部分,以通常的方式声明并定义了一个已初始化的 CMessage 对象 motto。在第 二条声明语句中,定义了一个指向 CMessage 对象的指针 pM,并使用 new 操作符为该指针指向的 CMessage 对象分配内存。对 new 操作符的调用将调用 CMessage 类的构造函数,结果是再次调用 new 操作符为数据成员 pmessage 指向的消息文本分配空间。如果构建并执行该示例,那么将得到下 面的输出: A miss is as good as a mile. A cat can look at a queen. Destructor called. Destructor called. 输出中记录了两次析构函数调用,用于两个 CMessage 对象。如前所述,编译器不负责删除在 空闲存储器中创建的对象。编译器之所以为对象 motto 调用析构函数,是因为虽然该对象的数据成 员占用的内存是由构造函数在空闲存储器中分配的,但它只是一个普通的自动对象。pM 指向的对 象就不同了。在空闲存储器中为该对象分配内存,因此必须使用 delete 将其删除。使下面这条出现 在 main()中 return 语句之前的语句是注释形式: // delete pM; // Manually delete object created with new 如果现在运行该程序,则得到下面的输出: A miss is as good as a mile. A cat can look at a queen. Destructor called. 现在,只调用了一次析构函数,这有些令人惊奇。显然,delete 只处理 main()函数中 new 操作 符分配的内存,即只释放指针 pM 指向的内存。因为指针 pM 指向一个 CMessage 对象(该类的析构 函数已经定义过),所以 delete 操作符还要调用析构函数来释放该对象的成员所占用的内存。因此, 当使用 delete 操作符删除动态创建的对象时,delete 操作符将在释放该对象占用的内存之前,首先调 用该对象的析构函数。这可以确保也释放为类成员动态分配的任何内存。 第 8 章 深入理解类 283 8.2 实现复制构造函数 当动态地为类成员分配空间时,在空闲存储器中存在着一些不合适的对象。就 CMessage 类来 说,默认的复制构造函数就不合适。假设写出下面这两条语句: CMessage motto1("Radiation fades your genes."); CMessage motto2(motto1); // Calls the default copy constructor 在这里,默认复制构造函数的作用是将类对象 motto1 的指针成员存储的地址复制到 motto2 中, 因为默认复制构造函数实现的复制过程只是将原来对象的数据成员中存储的值复制到新对象中。因 此,这两个对象将共享仅有的一个文本字符串,图 8-1 说明了这种情况。 复制 复制 地址 地址 地址 复制 空闲存储器中的字符串 空闲存储器中的字符串 对象 motto1 对象 motto1 对象 motto2 CMessage motto1(“ Radiation fades your genes.”) CMessage motto2(motto1); // Calls the default copy constructor 两个对象共享相同的字符串 图 8-1 在任何一个对象中对字符串进行的修改,也会修改另一个对象,因为两个对象共享相同的字符 串。如果销毁 motto1,那么 motto2 中的指针将指向已经被释放、现在可能用于其他对象的内存区域, 因此肯定会发生混乱。当然,如果删除 motto2,那么也会出现相同的问题。motto1 包含的指针成员 将指向一个不存在的字符串。 解决方案是提供一个类复制构造函数来代替默认版本。如下所示,可以在类的 public 部分实现 该函数: CMessage(const CMessage& aMess) { size_t len = strlen(aMess.pmessage)+1; pmessage = new char[len]; strcpy_s(pmessage, len, aMess.pmessage); } Visual C++ 2012 入门经典(第 6 版) 284 第 7 章讲过,为了避免对复制构造函数的无穷调用,必须将形参指定为 const 引用。该复制构 造函数首先分配足够容纳 aMess 对象中字符串的内存,将地址存入新对象的数据成员中,然后复制 初始化对象中的文本字符串。现在,新对象与旧对象相同,但与旧对象完全无关。 不要因为没有用另一个 CMessage 类对象初始化同类的对象,就认为我们是安全的,就不需要 为复制构造函数烦恼。另一个潜伏在空闲存储器中的“恶魔”可能在我们毫无防备的情况下,突然 冒出来攻击我们。考虑下面的语句: CMessage thought("Eye awl weighs yews my spell checker."); DisplayMessage(thought); // Call a function to output a message 其中 DisplayMessage()函数的定义如下所示: void DisplayMessage(CMessage localMsg) { cout << endl << "The message is: " ; localMsg.ShowIt(); return; } 看起来很简单,但代码中存在着致命的错误!函数 DisplayMessage()所做的事情实际上与此无关, 问题在于形参。形参是一个 CMessage 对象,因此调用过程中实参是通过传值方式传递的。使用默 认的复制构造函数,将发生一系列事件: (1) 创建 thought 对象,在空闲存储器中为消息“Eye awl weighs yews my spell checker”分配空间。 (2) 调用 DisplayMessage()函数。因为实参是通过传值方式传递的,所以使用默认复制构造函数 创建实参的副本 localMsg。现在,副本中的指针指向空闲存储器中原来的对象所指向的字符串。 (3) DisplayMessage()函数结束时,局部对象 localMsg 超出作用域,因此程序调用 CMessage 类 的析构函数,通过释放 pmessage 指针指向的内存,删除这个局部对象(副本)。 (4) 从 DisplayMessage()函数返回时,原来的对象 thought 包含的指针仍然指向刚释放的内存区 域。下次再使用原来的对象时,程序将表现出异常的行为。 如果某个类拥有动态定义的成员,又利用按值传递机制给函数传递该类的对象,那么对这个函 数的任何调用都将出错。因此,必须无条件地遵守下面这条规则: 8.3 在变量之间共享内存 由于过去 64KB 内存就算很大的了,因此 C++中有一个功能允许多个变量共享相同的内存(但显 然不能同时使用)。我们称之为联合,总共有 4 种使用联合的基本方法: ● 使变量 A 在程序中某个位置占用一块内存,稍后不需要 A 时再让不同类型的变量 B 占用 这块内存。本书不推荐这样做,我们不值得为可能隐含错误的这种方案冒险。通过动态的 内存分配,完全可以达到相同的结果。 如果动态地为类的成员分配空间,则必须实现复制构造函数。 第 8 章 深入理解类 285 ● 有时程序中需要大型的数组,但预先不知道元素的数据类型 —— 数据类型将由输入的数据 决定。本书同样不推荐在这种情况下使用联合,因为通过使用几个类型不同的指针,并再 次动态分配内存,也能达到相同的结果。 ● 联合的第三项用途是希望以两种或多种不同的方式解释相同数据的时候经常需要的。当有 一个 long 类型的变量,却希望将其当作两个 short 类型的数值对待时,就可能产生上述需求。 Windows 有时将两个 short 值包装在单个 long 类型的形参中传递给函数。如果我们希望将某 块包含数值数据的内存当作字节的字符串对待,以便能够四处移动这块内存,在这种情况 下可以使用联合。 ● 当我们预先不知道某个对象或数据值的类型时,可以使用联合来传递该对象或数据值。联 合可以存储任何在允许范围内的类型。 8.3.1 定义联合 使用关键字 union 定义联合。最好的理解方法是看一个定义的示例: union shareLD // Sharing memory between long and double { double dval; long lval; }; 这段代码定义了一个联合类型 shareLD,以允许 long 和 double 类型的变量占用相同的内存。 联合类型名通常称作标记名(tag name)。我们还没有真正定义过联合的实例,因此此刻还没有任何 变量,从这方面来讲该语句与类定义相似。在定义过联合类型之后,就可以在声明中定义联合的 实例。例如: shareLD myUnion; 该语句定义了前面定义的联合类型 shareLD 的一个实例。也可以将 myUnion 包括在联合的定义 语句中,来定义该实例: union shareLD // Sharing memory between long and double { double dval; long lval; } myUnion; 为了引用联合的成员,要使用直接成员选择操作符(.)与联合实例的名称,就像访问类成员时那 样。因此,使用下面这条语句可以将联合实例 MyUnion 中的 long 型变量 lval 设定为 100: myUnion.lval = 100; // Using a member of a union 在后面的程序中,使用类似的语句初始化 double 类型的变量 dval 将改写 lval。使用联合可以在 相同内存中存储不同类型的值,随之而来的问题是由于联合的特殊工作方式,必须有某种方法来确 定当前的成员值。为此,通常还需要用一个变量来充当当前值的类型指示器。 Visual C++ 2012 入门经典(第 6 版) 286 联合不仅仅限于两个变量之间的共享。如果 愿意,那么可以使多个变量共享相同的内存。联 合占用的内存大小取决于最大的成员。例如,假 设定义了下面这个联合: union shareDLF { double dval; long lval; float fval; } uinst = {1.5}; 如图 8-2 所示,shareDLF 的实例将占用 8 个 字节。 在本例中,定义了该联合的实例 uinst,并定 义了联合的标记名,还将实例初始化为值 1.5。 8.3.2 匿名联合 可以定义没有联合类型名的联合,这种情况下程序将自动声明该联合的一个实例。例如,假设 定义了下面这个联合: union { char* pval; double dval; long lval; }; 该语句既定义了一个匿名联合,又定义了该联合的一个匿名实例。因此,只需要借助联合定义 中出现的变量名 pval、dval 和 lval,就能引用该实例包含的变量。这样可能比普通的带有类型名的 联合更方便,但我们需要小心避免在联合成员与普通变量之间出现混淆。联合的成员仍然共享相同 的内存。为了说明该匿名联合的工作原理,可以编写下面这条语句来使用 double 成员: dval = 99.5; // Using a member of an anonymous union 可以看出,dval 作为联合的成员无法与变量 dval 区分开。如果需要使用匿名联合,那么可以使 用某种命名约定,以使联合成员的身份更加明显,从而使代码不容易被误解。 8.3.3 类和结构中的联合 可以在类或结构中包括联合的实例。如果打算在不同时间存储不同类型的数值,那么通常需要 使用一个类数据成员来指示联合中存储的值类型。将联合用作类或结构的成员,通常用处不大。 当声明一个实例时,只能初始化联合的第一个成员。 8 字节 lval fval dval shareDLF 联合实例 图 8-2 第 8 章 深入理解类 287 8.4 运算符重载 运算符重载是非常重要的功能,因为它能够使用像+、–、*这样的标准 C++运算符,来处理 自定义数据类型的对象。该功能允许编写重新定义特定运算符的函数,从而使该运算符处理类对 象时执行特定的动作。例如,可以重新定义<运算符,从而使该运算符用于前面看到的 CBox 类对象 时,如果第一个实参的体积比第二个小,就返回 true。 运算符重载功能不允许使用新的运算符,也不允许改变运算符的优先级,因此运算符的重载版 本在计算表达式的值时,优先级与原来的基本运算符相同。运算符的优先级表可以在本书第 2 章和 MSDN 库中找到。 虽然我们不能重载所有运算符,但限制不是特别严格。下面给出了不能重载的运算操作符。 :: 作用域解析运算符 ?: 条件运算符 . 直接成员选择操作符 sizeof sizeof 操作符 .* 对指向类成员的指针解除引用的操作符 任何其他运算符都是可以重载的,这给予我们相当大的灵活性。显然,确保标准运算符的重载 版本与原来的正常用途一致,或者至少在操作上相当直观,是正确的想法。如果为某个类重载的+ 运算符执行使类对象相乘的操作,这可能就不是明智的做法。理解运算符重载机制如何工作的最好 方法是完成一个示例,因此下面为 CBox 类实现刚才提到的小于运算符<。 8.4.1 实现重载的运算符 为了给某个类实现重载的运算符,必须编写特殊的函数。假设在类定义内重载<运算符的函数 是 CBox 类的成员,则该函数的声明如下所示: class CBox { public: bool operator<(const CBox& aBox) const; // Overloaded 'less than' // Rest of the class definition... }; 这里的单词 operator 是个关键字。该关键字结合运算符符号或名称,本例中是<,将定义一个运 算符函数。本例中的函数名是 operator<()。在运算符函数的声明中,关键字 operator 和运算符之间 有无空格都行,前提是没有歧义。歧义出现在运算符是名称而非符号的时候,如 new 或 delete。如 果写成不加空格的 operatornew 和 operatordelete,则它们都是合法的普通函数名。因此,如果要编写 这些运算符的运算符函数,则必须在关键字 operator 和运算符名称之间加个空格。重载运算符函数 看起来最奇怪的函数名是 operator()()。看起来似乎是输入错误,实则不然。事实上,它是重载函 数调用运算符()的一个函数。注意,将 operator<()函数声明为 const,因为该函数不修改本类的数 据成员。 Visual C++ 2012 入门经典(第 6 版) 288 在 operator<()运算符函数中,运算符的右操作数由函数形参定义,左操作数由 this 指针隐式定 义。因此,如果有下面这条 if 语句: if(box1 < box2) cout << endl << "box1 is less than box2"; 则括号中的表达式将调用重载的运算符函数,它与下面这个函数调用等价: if(box1.operator<(box2)) cout << endl << “box1 is less than box2”; 表达式中的 CBox 对象与运算符函数形参之间的对应关系如图 8-3 所示。 函数实参 this 指针指向的对象 图 8-3 下面介绍 operator<()函数的工作原理: // Operator function for 'less than' which // compares volumes of CBox objects. bool CBox::operator < (const CBox & aBox) const { return this->Volume() < aBox.Volume(); } 该函数使用引用形参,以避免调用时不必要的复制开销。因为该函数不需要修改调用它的对象, 所以可将其声明为 const。如果不这样做,则根本不能使用该运算符比较 CBox 类型的 const 对象。 该函数也声明为 const,是因为该函数不需要修改调用它的 CBox 对象。 return 表达式使用成员函数 Vo lu me( )计算 this 指向的 CBox 对象的体积,然后使用基本运算符< 比较结果与对象 aBox 的体积。 对 CBox 对象进行相等比较也很容易实现: bool CBox::operator==(const CBox& aBox) const { return this->Volume() == aBox.Volume(); } 如果两个对象的体积相同,这段代码就假定两个对象相等。也可以将相等定义为其他方式。相 等比较的更严格、更现实的实现方法是对象有相同的尺寸,这留给读者来思考。 第 8 章 深入理解类 289 试一试:运算符重载 可以通过如下示例练习如何使用 CBox 对象的 operator<()函数: // Ex8_03.cpp // Exercising the overloaded 'less than' operator and equality operators #include // For stream I/O using std::cout; using std::endl; class CBox // Class definition at global scope { public: // Constructor definition explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0): m_Length(lv), m_Width(wv), m_Height(hv) { cout << endl << "Constructor called."; } // Function to calculate the volume of a box double Volume() const { return m_Length*m_Width*m_Height; } bool operator < (const CBox& aBox) const; // Overloaded 'less than' // Overloaded equality operator bool operator==(const CBox& aBox) const { return this->Volume() == aBox.Volume(); } // Destructor definition ~CBox() { cout << "Destructor called." << endl; } private: double m_Length; // Length of a box in inches double m_Width; // Width of a box in inches double m_Height; // Height of a box in inches }; // Operator function for 'less than' that // compares volumes of CBox objects. inline bool CBox::operator < (const CBox& aBox) const { return this->Volume() < aBox.Volume(); } int main() { CBox smallBox(4.0, 2.0, 1.0); CBox mediumBox(10.0, 4.0, 2.0); CBox bigBox(30.0, 20.0, 40.0); Visual C++ 2012 入门经典(第 6 版) 290 CBox thatBox(4.0, 2.0, 10.0); if(mediumBox < smallBox) cout << endl << "mediumBox is smaller than smallBox"; if(mediumBox < bigBox) cout << endl << "mediumBox is smaller than bigBox"; else cout << endl << "mediumBox is not bigger than bigBox"; if(thatBox == mediumBox) cout << endl << “thatBox is equal to mediumBox”; else cout << endl << “thatBox is not equal to mediumBox”; cout << endl; return 0; } 示例说明 operator<()运算符函数的原型出现在类的 public 部分。由于函数定义在类定义外部,因此该函数 默认不是内联函数,所以显式指定它。通常情况下最好把简单的演算法函数设置为内联的,这样代 码运行速度会更快。把函数定义放在类定义外部的唯一原因是演示这种可能性。完全可以将函数定 义放在类定义中,就像 operator==()函数那样。这种情况下将不需要指定 inline 或者在函数名前面用 CBox 加以限定。我们记得,当函数成员在类定义外部定义时,为了告诉编译器该函数是 CBox 类的 成员,必须用类名进行限定。 main()函数中有两条对类成员使用<运算符的 if 语句,它们将自动调用重载的运算符函数。如果 想确认这一点,那么可以给运算符函数添加一条输出语句。此外还有一条使用==运算符的语句。该示例 的输出如下: Constructor called. Constructor called. Constructor called. Constructor called. mediumBox is smaller than bigBox thatBox is equal to mediumBox Destructor called. Destructor called. Destructor called. Destructor called. 输出证实,使用运算符函数的 if 语句工作正常,重载的运算符可用于 const 和非 const 对象。因 此直接用 CBox 对象表示 CBox 问题的解决方案开始成为很现实的命题。 8.4.2 实现对比较运算符的完全支持 有了前面实现的 operator<()运算符函数,我们仍然有许多事情不能做。用 CBox 对象指定问题 的解决方案可能涉及像下面这样的语句: if(aBox < 20.0) // Do something... 函数不会处理这里的表达式。如果试图使用比较 CBox 对象与数值的表达式,那么将得到一条 错误消息。为了支持该功能,需要编写另一个版本的 operator<()函数作为重载函数。 第 8 章 深入理解类 291 要支持刚刚看到的表达式类型非常容易。类内的成员函数定义如下所示: // Function to compare a CBox object with a constant bool CBox::operator<(const double& value) const { return this->Volume() < value; } <运算符的右操作数对应于这里的函数形参。作为左操作数的 CBox 对象是由隐式指针 this 传递 的。没有比这更简单的事情了,不是吗?但使用<运算符处理 CBox 对象仍然存在问题。我们希望写 出下面这样的语句: if(20.0 < aBox) // do something... 有人可能认为,实现接受 double 类型右实参的 operator>()运算符函数,然后相应重写上面这条 语句,同样可以完成相同的功能,这么说非常正确。实际上无论如何,实现>运算符都是比较 CBox 对象所必需的。但是,在实现对某种对象类型的支持时,不应该人为地限制在表达式中使用这种对 象的方式。对象的使用应该尽可能自然。现在的问题是如何来做。 成员运算符函数总是以左边的实参作为指针 this。因为本例中左边的实参是 double 类型,所以 不能以成员函数的形式实现该运算符。剩下的只有两种选择:普通函数或友元函数。因为不需要访 问 CBox 类的 private 成员,所以该函数不必是友元函数。这样可以将左操作数属于 double 类型的重 载<运算符实现为普通函数,如下所示: // Function comparing a constant with a CBox object inline bool operator<(const double& value, const CBox& aBox) { return value < aBox.Volume(); } 如前所述,普通函数(就这一点而论也包括友元函数)使用直接成员选择运算符和对象名访问对 象的公有成员。成员函数 Vo lu me( )是公有的,因此这里使用该函数没有问题。 如果 CBox 类没有公有函数 Vo lu me( ),可以直接将运算符函数声明为能够直接访问私有数据成 员的友元函数,或者提供一组返回私有数据成员数值的成员函数,然后在普通函数中使用这些函数 来实现比较功能。 还有另一种方式。我们需要用到>、>=、<=和!=运算符。读者可以自己实现这些运算符,也可 以使用标准库所提供的。Utility 头文件为运算符函数定义了一组模板,如下所示: template bool operator!=(const T& x, const T& y); // Requires == template bool operator>(const T& x, const T& y); // Requires < template bool operator<=(const T& x, const T& y); // Requires < template bool operator>=(const T& x, const T& y); // Requires < 这些模板会给任意类创建比较运算符函数。上面的注释说明必须实现 operator<()和 operator==(), 这些模板才能用于类。这些模板在 std::rel_ops 名称空间中定义,所以可以在源文件中使用下面的 using 指令启用这些模板: using namespace std::rel_ops; Visual C++ 2012 入门经典(第 6 版) 292 有了上述指令,就可以对类自由运用这 4 个附加的运算符函数。下面就试一试。 试一试:完成>比较运算符的完全重载 可以在示例中将这些放在一起以说明实际的工作过程: // Ex8_04.cpp // Implementing the comparison operators #include // For stream I/O #include // For operator overload templates using std::cout; using std::endl; using namespace std::rel_ops; class CBox // Class definition at global scope { public: // Constructor definition explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0): m_Length(lv), m_Width(wv), m_Height(hv) { cout << endl << "Constructor called."; } // Function to calculate the volume of a box double Volume() const { return m_Length*m_Width*m_Height; } // Operator function for 'less than' that // compares volumes of CBox objects. bool operator<(const CBox& aBox) const { return this->Volume() < aBox.Volume(); } // 'Less than' operator function to compare a CBox object volume with a constant bool operator<(const double& value) const { return this->Volume() < value; } // 'Greater than' function to compare a CBox object volume with a constant bool operator>(const double& value) const { return this->Volume() > value; } // Overloaded equality operator bool operator==(const CBox& aBox) const { return this->Volume() == aBox.Volume(); } // Destructor definition 第 8 章 深入理解类 293 ~CBox() { cout << "Destructor called." << endl;} private: double m_Length; // Length of a box in inches double m_Width; // Width of a box in inches double m_Height; // Height of a box in inches }; // Function comparing a constant with a CBox object inline bool operator<(const double& value, const CBox& aBox) { return value < aBox.Volume(); } int main() { CBox smallBox(4.0, 2.0, 1.0); CBox mediumBox(10.0, 4.0, 2.0); CBox otherBox(2.0, 1.0, 4.0); if(mediumBox != smallBox) cout << endl << "mediumBox is not equal to smallBox"; if(mediumBox > smallBox) cout << endl << "mediumBox is bigger than smallBox"; else cout << endl << "mediumBox is not bigger than smallBox"; if(otherBox >= smallBox) cout << endl << "otherBox is greater than or equal to smallBox"; else cout << endl << "otherBox is smaller than smallBox"; if(otherBox >= mediumBox) cout << endl << "otherBox is greater than or equal to mediumBox"; else cout << endl << "otherBox is smaller than mediumBox"; if(mediumBox > 50.0) cout << endl << "mediumBox capacity is more than 50"; else cout << endl << "mediumBox capacity is not more than 50"; if(10.0 < smallBox) cout << endl << "smallBox capacity is more than 10"; else cout << endl << "smallBox capacity is not more than 10"; cout << endl; return 0; } 示例说明 注意普通版本 operator>()函数的原型所处的位置。该原型需要跟在类定义后面,因为它要引用 Visual C++ 2012 入门经典(第 6 版) 294 CBox 对象。如果将其放在类定义前面,则将不能编译该示例,因为那时 CBox 类型还没有定义。 有一种将其放在程序文件开头#include 语句后面的方法:使用未完成的类声明,也称为类类型 的前向声明。这样该函数的原型就可以放在类定义之前,声明语句如下所示: class CBox; // Incomplete class declaration inline bool operator < (const double& value, const CBox& aBox); // Prototype 前向声明告诉编译器 CBox 是一个类,这足以使编译器正确处理第二行的函数原型,因为它现 在知道 CBox 是后面将要指定的用户定义类型。 如果有两个类,而每个类都有一个指针成员指向另一个类的对象,在此类情形中同样需要上述 机制。这两个类都要求首先声明另一个类,通过使用未完成的类声明就可以打破这样的僵局。 8.4.3 重载赋值运算符 如果我们不亲自给类提供重载的赋值运算符函数,则编译器将提供一个默认的函数。默认版本 注意:前向声明可以用于类类型和 enum 类型。例如: enum class Suit; 即使还没有定义 enum 类型, 这个前向声明也允许在该语句后使用 Suit enum 来声 明变量。但是在提供该 enum 的完整定义之前,不能引用 enum 类型的枚举值。 这个示例的输出如下: Constructor called. Constructor called. Constructor called. mediumBox is not equal to smallBox mediumBox is bigger than smallBox otherBox is greater than or equal to smallBox otherBox is smaller than mediumBox mediumBox capacity is more than 50 smallBox capacity is not more than 10 Destructor called. Destructor called. Destructor called. 在声明 CBox 对象而输出的构造函数消息之后是 if 语句的输出, 每条语句都像我 们预期的那样工作。其中第一个语句调用了 operator!=()函数,它是由编译器从 utility 头文件提供的模板中生成的。模板需要类中有==运算符函数的定义。 输出结果说明模板生成的所有运算符函数都是有效的。目前, 我们把两个运算符 函数都定义为类的普通成员函数,因为它们都只需要访问公有的 Vo lume ( )函数,该函 数为 pulic。 任何类类型的比较运算符都可以用这里演示的方式来实现, 它们仅在细节方面有 些不同,这取决于对象的本质。 enum class Suit{Clubs, Diamonds, Hearts, Spades}; 第 8 章 深入理解类 295 只提供逐个成员的复制过程,与默认复制构造函数的功能类似;但是,不要混淆默认复制构造函数 与默认赋值运算符。当定义以现有的同类对象进行初始化的类对象,或者通过以传值方式给函数传 递对象时,调用默认复制构造函数。另一方面,当赋值语句的左边和右边是同类类型的对象时,调 用默认赋值运算符。 就 CBox 类来说,使用默认赋值运算符没有任何问题,但对于那些给成员动态分配空间的类而 言,就需要仔细考虑这些类的要求。如果在此类情形中不考虑赋值运算符,则程序中可能产生混乱。 让我们暂时返回到讨论复制构造函数时使用的 CMessage 类。记得该类有个成员 pmessage,它 是指向字符串的指针。现在考虑默认赋值运算符可能产生的结果。假设有该类的两个实例 motto1 和 motto2。如下所示,可以尝试使用默认赋值运算符,使 motto2 的成员等于 motto1 的成员。 motto2 = motto1; // Use default assignment operator 为该类使用默认赋值运算符的结果基本上与使用默认复制构造函数相同,即灾难降临!因为两 个对象都有一个指向相同字符串的指针,所以只要修改一个对象的字符串,受影响的就是两个对象。 另外一个问题是:当销毁该类的实例之一时,其析构函数将释放该字符串占用的内存,因此另一个 对象包含的指针将指向可能已经被其他对象占用的内存。我们需要赋值运算符做的事情是将源对象 的文本复制到目标对象所拥有的内存区域。 修正问题 可以使用自己的赋值运算符函数来修正上述问题,该函数在类定义内部定义。下面仅是基本代 码,目前还不足以执行正确的操作: // Overloaded assignment operator for CMessage objects CMessage& operator=(const CMessage& aMess) { // Release memory for 1st operand delete[] pmessage; pmessage = new char[strlen(aMess.pmessage) + 1]; // Copy 2nd operand string to 1st strcpy_s(this->pmessage, strlen(aMess.pmessage) + 1, aMess.pmessage); // Return a reference to 1st operand return *this; } 这里的赋值看起来非常简单,但几点微妙之处需要进一步深究。首先要注意的是,从赋值运算 符函数中返回的是引用。这么做的理由似乎并不一目了然——毕竟,赋值运算符函数确实能够完成 赋值操作,将复制赋值运算符右边的对象到左边。表面上看,返回引用意味着不需要返回任何东西, 但需要进一步考虑该运算符的使用方式。 有时可能需要在表达式的右边使用赋值操作的结果,考虑下面这条语句: motto1 = motto2 = motto3; 因为赋值运算符具有右结合性(right-associative),即首先执行将 motto3 赋给 motto2 的操作,所 以该语句可翻译成下面这条语句: motto1 = (motto2.operator=(motto3)); Visual C++ 2012 入门经典(第 6 版) 296 此处运算符函数调用的结果在等号的右边,因此该语句最终变为: motto1.operator=(motto2.operator=(motto3)); 要使这条语句工作,当然必须有返回对象。括号内对 operator=()函数的调用必须返回一个对象 作为另一个 operator=()函数调用的实参。本例中,返回类型为 CMessage 或 CMessage&都可以,在 此类情形中返回引用不是必需的,但无论如何都必须返回 CMessage 对象。 但是,考虑下面的例子: (motto1 = motto2) = motto3; 这是完全合法的代码,括号旨在确保首先执行最左边的赋值。该语句可翻译成下面的语句: (motto1.operator=(motto2)) = motto3; 当将剩下的赋值操作表示成显式的重载函数调用时,该语句最终变为: (motto1.operator=(motto2)).operator=(motto3); 现在的情况是,从函数 operator=()返回的对象用来调用 operator=()函数。如果返回类型仅仅是 CMessage,则该语句是不合法的,因为实际返回的是原始对象的临时副本,它是 rvalue,编译器不 允许使用 rvalue 调用成员函数。确保此类语句能够正确编译和工作的唯一方法是返回一个引用,它 是 lvalue,因此如果希望实现使用赋值运算符处理类对象的灵活性,则唯一可能的返回类型是 CMessage&。 注意,C++语言对赋值运算符的形参和返回类型没有任何限制,但如果希望自己的赋值运算符 函数支持常规的赋值用法,那么以刚才描述的方式声明赋值运算符就具有现实意义。 第二点微妙之处是,两个对象都已经拥有为字符串分配的内存,因此赋值运算符函数首先要删 除分配给第一个对象的内存,然后重新分配足够的内存,以容纳属于第二个对象的字符串。做完这 件事之后,就可以将来自第二个对象的字符串复制到第一个对象现在拥有的新内存中。 该运算符函数中仍然存在缺点。如果写出下面这条语句,那么将发生什么事情呢? motto1 = motto1; 显然,我们不会直接那样做,但此类现象很容易隐藏在指针的背后,就像在下面的语句中的那样: Motto1 = *pMessage; 如果指针 pMessage 指向 motto1,那么实质上这是前面那条赋值语句。这种情况下,目前的赋 值运算符函数将释放供 motto1 使用的内存,然后基于已删除的字符串的长度另外分配一些内存,并 试图复制当时很可能已经被破坏的旧内存。通过在函数的开头检查左右操作数是否相同,就可以修 正上述问题,因此现在 operator=()函数的定义将如下所示: // Overloaded assignment operator for CMessage objects CMessage& operator=(const CMessage& aMess) { if(this != &aMess) // Check addresses are not equal { // Release memory for 1st operand delete[] pmessage; pmessage = new char[strlen(aMess.pmessage) + 1]; // Copy 2nd operand string to 1st 第 8 章 深入理解类 297 strcpy_s(this->pmessage, strlen(aMess.pmessage)+1, aMess.pmessage); } // Return a reference to 1st operand return *this; } 试一试:重载赋值运算符 下面将完整的 operator=()函数代码放在可运行的示例中,同时还向 CMessage 类中添加一个 Reset()成员函数。该函数的作用仅是将消息重新设置为星号字符串。 // Ex8_05.cpp // Overloaded assignment operator working well #include #include using std::cout; using std::endl; class CMessage { private: char* pmessage; // Pointer to object text string public: // Function to display a message void ShowIt() const { cout << endl << pmessage; } //Function to reset a message to * void Reset() { char* temp = pmessage; while(*temp) *(temp++) = '*'; } // Overloaded assignment operator for CMessage objects CMessage& operator=(const CMessage& aMess) { if(this != &aMess) // Check addresses are not equal { // Release memory for 1st operand delete[] pmessage; pmessage = new char[strlen(aMess.pmessage) + 1]; // Copy 2nd operand string to 1st strcpy_s(this->pmessage, strlen(aMess.pmessage) + 1, aMess.pmessage); } // Return a reference to 1st operand return *this; } // Constructor definition Visual C++ 2012 入门经典(第 6 版) 298 CMessage(const char* text = "Default message") { pmessage = new char[strlen(text) + 1]; // Allocate space for text strcpy_s(pmessage, strlen(text)+1, text); // Copy text to new memory } // Copy constructor definition CMessage(const CMessage& aMess) { size_t len = strlen(aMess.pmessage)+1; pmessage = new char[len]; strcpy_s(pmessage, len, aMess.pmessage); } // Destructor to free memory allocated by new ~CMessage() { cout << "Destructor called." // Just to track what happens << endl; delete[] pmessage; // Free memory assigned to pointer } }; int main() { CMessage motto1("The devil takes care of his own"); CMessage motto2; cout << "motto2 contains - "; motto2.ShowIt(); cout << endl; motto2 = motto1; // Use new assignment operator cout << "motto2 contains - "; motto2.ShowIt(); cout << endl; motto1.Reset(); // Setting motto1 to * doesn't // affect motto2 cout << "motto1 now contains - "; motto1.ShowIt(); cout << endl; cout << "motto2 still contains - "; motto2.ShowIt(); cout << endl; return 0; } 从该程序的输出可以看出,一切都完全按照要求工作,两个对象的消息之间没有任何联系: motto2 contains - Default message motto2 contains - The devil takes care of his own motto1 now contains - ******************************* motto2 still contains - 第 8 章 深入理解类 299 The devil takes care of his own Destructor called. Destructor called. 由此得到另一条黄金规则: 如果需要给类的数据成员动态分配空间,则必须实现赋值运算符。 实现赋值运算符之后,在+=这样的操作中将发生什么事情呢?除非实现这样的运算符,否则它 们不能工作。对于希望用来处理类对象的每种 op=形式,都需要编写另一个运算符函数。 8.4.4 重载加法运算符 本节将介绍如何为 CBox 类重载加法运算符。这是个有趣的问题,因为涉及创建和返回新的 对象。新对象是两个操作数(两个 CBox 对象)的和(无论将和的意义定义成什么)。 那么,我们真正希望两个箱子的和是什么?关于这一点有很多合法的可能性,但我们在这里将 力求简单。下面这样定义两个 CBox 对象的和:它是个 CBox 对象,其体积足够容纳这两个摞在一 起的箱子。理想情况下,可通过把两个箱子的最短尺寸合并起来将它们连接起来。为此,可以确保 箱子的长度总是大于或等于宽度,且箱子的宽度总是大于或等于高度。接着使新对象的 m_Length 成员等于两个相加对象中较大的 m_Length 成员,并以类似的方式求出 m_Width 成员,然后使 m_Height 成员等于两个操作数对象的 m_Height 成员的和,即可使合成的 CBox 对象足以包含这两 个 CBox 对象。这种实现方法未必是最优的解决方案,因为两个箱子可以绕着高度轴旋转,得到更 有效的合并方式,但就我们的目的而言已经足够。通过修改构造函数,还将使 CBox 对象的长度、 宽度和高度按降序排列。 CBox 类的加法运算符版本更容易通过图形来解释,因此在图 8-4 中阐明了相加的过程。 最大宽度 最大长度 高度之和 图 8-4 Visual C++ 2012 入门经典(第 6 版) 300 因为需要访问 CBox 对象的私有成员,所以应该将 operator+()实现为该类的成员函数。该函数 成员在类定义内部的声明如下所示: CBox operator+(const CBox& aBox) const; // Function adding two CBox objects 我们将形参定义成引用,以避免调用该函数时不必要地复制右边的实参。我们还将形参声明为 const 引用,因为该函数不修改实参。如果不把形参声明为 const 引用,则编译器不允许将 const 对 象传递给这个函数,因此+运算符的右操作数不可以是 const CBox 对象。我们将该运算符函数也声 明为 const,因为它不修改调用它的那个对象。如果不这样做,则+运算符的左操作数也不可以是 const CBox 对象。 operator+()函数的定义现在如下所示: // Function to add two CBox objects CBox CBox::operator+(const CBox& aBox) const { // New object has larger length and width, and sum of heights return CBox(m_Length > aBox.m_Length ? m_Length : aBox.m_Length, m_Width > aBox.m_Width ? m_Width : aBox.m_Width, m_Height + aBox.m_Height); } 根据当前对象(*this)和传递的实参对象 aBox,我们构造出一个局部的 CBox 对象。记住,返回 过程将创建局部对象的临时副本,并将此副本(而非从函数返回时将被抛弃的局部对象)返回给调用 程序。 试一试:练习使用重载的加法运算符 在这个示例中,我们将能够看到 CBox 类中重载加法运算符的工作过程。 // Ex8_06.cpp // Adding CBox objects #include // For stream I/O #include // For operator overload templates using std::cout; using std::endl; using namespace std::rel_ops; class CBox // Class definition at global scope { public: // Constructor definition explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0): m_Height(hv) { m_Length = std::max(lv, wv); m_Width = std::min(lv, wv); // Length is now greater than or equal to width if(m_Height > m_Length) { m_Height = m_Length; m_Length = hv; 第 8 章 深入理解类 301 // m_Height is still greater than m_Width so swap them double temp = m_Width; m_Width = m_Height; m_Height = temp; } else if( m_Height > m_Width) { m_Height = m_Width; m_Width = hv; } } // Function to calculate the volume of a box double Volume() const { return m_Length*m_Width*m_Height; } // Operator function for 'less than' that // compares volumes of CBox objects. bool operator<(const CBox& aBox) const { return this->Volume() < aBox.Volume(); } // 'Less than' operator function to compare a CBox object volume with a constant bool operator<(const double& value) const { return this->Volume() < value; } // 'Greater than' function to compare a CBox object volume with a constant bool operator>(const double& value) const { return this->Volume() > value; } // Overloaded equality operator bool operator==(const CBox& aBox) const { return this->Volume() == aBox.Volume(); } // Function to add two CBox objects CBox operator+(const CBox& aBox) const { // New object has larger length & width, and sum of heights return CBox(m_Length > aBox.m_Length ? m_Length : aBox.m_Length, m_Width > aBox.m_Width ? m_Width : aBox.m_Width, m_Height + aBox.m_Height); } // Function to show the dimensions of a box void ShowBox() const { cout << m_Length << " " << m_Width << " " << m_Height << endl; } Visual C++ 2012 入门经典(第 6 版) 302 private: double m_Length; // Length of a box in inches double m_Width; // Width of a box in inches double m_Height; // Height of a box in inches }; // Function comparing a constant with a CBox object inline bool operator>(const double& value, const CBox& aBox) { return value > aBox.Volume(); } int main() { CBox smallBox(4.0, 2.0, 1.0); CBox mediumBox(10.0, 4.0, 2.0); CBox aBox; CBox bBox; cout << "smallBox dimensions are "; smallBox.ShowBox(); cout << "mediumBox dimensions are "; mediumBox.ShowBox(); aBox = smallBox + mediumBox; cout << "aBox = smallBox + mediumBox. Dimensions are " aBox.ShowBox(); bBox = aBox + smallBox + mediumBox; cout << "bBox = aBox + smallBox + mediumBox. Dimensions are " bBox.ShowBox(); return 0; } 本章后面将再次使用 CBox 类的定义。因此现在请在书上作好记号,稍后可能需要返回到这里。 示例说明 在该示例中,我们稍微修改了 CBox 类的成员。我们删除了析构函数,因为对 CBox 类而言它 不是必需的。构造函数修改成确保 m_Length、m_Width 和 m_Height 成员按降序排列,这使用 C++ 库中的 max()和 min()函数来实现。我们还添加了一个输出 CBox 对象尺寸的 ShowBox()函数。使用 该函数能够验证重载的加法操作是否像预期的那样工作。 该程序的输出如下: smallBox dimensions are 4 2 1 mediumBox dimensions are 10 4 2 aBox = smallBox + mediumBox. Dimensions are 10 4 3 bBox = aBox + smallBox + mediumBox. Dimensions are 10 6 4 该输出看来与前面定义的使 CBox 对象相加的概念一致。另外可以看出,该函数还可以处理表 达式中的多次加法操作。为了计算 bBox,调用了两次重载的加法运算符。结果显示,这种合并方式 并不是最优的。 第 8 章 深入理解类 303 也可以以友元函数的形式实现 CBox 类的加法操作,其原型如下: friend CBox operator+(const CBox& aBox, const CBox& bBox); 除需要使用直接成员选择操作符获得函数两个实参的成员以外,得出结果的过程将完全相同。 8.4.5 重载递增和递减运算符 本节简要介绍在类中重载递增和递减运算符的机制,因为它们有一些区别于其他一元运算符的 特性。我们需要一种方法来处理++和--运算符有前缀和后缀两种形式,而且根据其是否在其前缀或 后缀形式中应用运算符结果是不同的。对应于递增、递减运算符的前缀和后缀形式的重载运算符是 不同的。例如,下面是在名为 Length 的类中定义这些运算符的方法: class Length { private: double len; // Length value for the class public: Length& operator++(); // Prefix increment operator const Length operator++(int); // Postfix increment operator Length& operator--(); // Prefix decrement operator const Length operator--(int); // Postfix decrement operator // rest of the class... } 这个简单的类只是将长度存储为 double 类型的值。在现实中,也许需要定义更复杂的 length 类, 但此处的代码旨在说明如何重载递增和递减运算符。 区分重载运算符前缀和后缀形式的首要方法是利用形参列表。前缀形式没有形参,后缀形式有 一个 int 类型的形参。后缀运算符函数的形参只是为了将其同前缀形式区别开来,除此之外它在函 数实现中没有任何用处。 前缀形式的递增和递减运算符在表达式中使用操作数的值之前将其递增或递减,因此在递增或 递减当前对象之后,只需要返回该对象的引用即可。下面是 Length 类的前缀 operator++()函数的实 现示例: Length& Length::operator++() { ++(this->len); return *this; } 而在后缀形式中,操作数是在表达式中使用其当前值之后递增的。要实现这一点,需要在递增 当前对象之前创建当前对象的副本,并在修改过当前对象之后返回新创建的副本对象。下面是实现 重载 Length 类的后缀++运算符的函数示例: const Length Length::operator++(int) { Length length = *this; // Copy the current object ++*this; // Increment the current object Visual C++ 2012 入门经典(第 6 版) 304 return length; // Return the original copy } 复制了当前对象后,使用类的前缀++运算符递增它,然后返回当前对象原来未递增时的副本, 运算符所在的表达式中使用的正是这个值。将返回值声明为const,以防止编译类似 data++++这样的 表达式。 8.4.6 重载函数调用操作符 函数调用操作符是(),因此,此操作符的函数重载是 operator()()。重载函数调用操作符的类对象 称为函数对象或仿函数(functor),因为我们可以像使用函数名一样使用对象名。先来看一个简单的 例子。下面是重载了函数调用操作符的一个类: class Area { public: int operator()(int length, int width) { return length*width; } }; 此类中的操作符函数计算一个面积,它是两个整数实参的乘积。为了使用此操作符函数,只需 要创建一个类型为 Area 的对象,例如: Area area; // Create function object int pitchLength(100), pitchWidth(50); int pitchArea = area(pitchLength, pitchWidth); // Execute function call overload 第一条语句创建第三条语句中使用的 area 对象,第三条语句使用此对象来调用对象的函数调用 操作符。在此例中,返回的是足球场的面积。 当然,也可以将一个函数对象传递给另一个函数,就像传递任何其他对象一样。看看下面这个 函数: void printArea(int length, int width, Area& area) { cout << "Area is " << area(length, width); } 下面是使用此函数的语句: printArea(20, 35, Area()); 这条语句调用 printArea()函数,前两个实参分别指定矩形的长和宽。第三个实参调用默认构造 函数,以创建一个 Area 对象,函数中计算面积时要使用此 Area 对象。因此,函数对象提供了一种 方式,可以将函数作为实参传递给另一个函数。与使用函数指针相比,这种方式既简单又容易。 定义函数对象的类一般不需要数据成员,也没有定义的构造函数,因此创建和使用函数对象的 开销是最小的。函数对象类通常也定义为模板,因为这会增加灵活性,本章稍后将会介绍这一点。 注意:第 10 章将学习 std::function<>模板,它为传递函数提供了更大的灵活性。 第 8 章 深入理解类 305 8.5 对象复制问题 按值传递实参到函数时复制是隐式进行的。当实参是基本类型时,这样没有问题。但是,如果 实参是类类型的对象时,这就可能有问题了。对象复制操作所产生的系统开销会很大,尤其是当对 象很大或占用的内存是动态分配时。对象复制是通过调用类的复制构造函数完成的,因此,此类函 数的效率对于执行性能来说至关重要。在讨论 CMessage 类时我们已看到过,赋值运算符也涉及对 象的复制。但是,有些情况下,这样的复制操作实际上是没有必要的,如果能够找到合适的办法避 免这样的复制操作,则执行时间可能会大大缩短。rvalue 引用实参是解决这一问题的关键。 8.5.1 避免不必要的复制操作 通过修改 Ex8_05.cpp 的CMessage 类,我们来看看如何避免不必要的复制操作。下面是 CMessage 类实现相加运算符之后的版本。 class CMessage { private: char* pmessage; // Pointer to object text string public: // Function to display a message void ShowIt() const { cout << endl << pmessage; } // Overloaded addition operator CMessage operator+(const CMessage& aMess) const { cout << "Add operator function called." << endl; size_t len = strlen(pmessage) + strlen(aMess.pmessage) + 1; CMessage message; message.pmessage = new char[len]; strcpy_s(message.pmessage, len, pmessage); strcat_s(message.pmessage, len, aMess.pmessage); return message; } // Overloaded assignment operator for CMessage objects CMessage& operator=(const CMessage& aMess) { cout << "Assignment operator function called." << endl; if(this != &aMess) // Check addresses are not equal { // Release memory for 1st operand delete[] pmessage; pmessage = new char[strlen(aMess.pmessage) + 1]; // Copy 2nd operand string to 1st strcpy_s(this->pmessage, strlen(aMess.pmessage)+1, aMess.pmessage); } return *this; // Return a reference to 1st operand } Visual C++ 2012 入门经典(第 6 版) 306 // Constructor definition CMessage(const char* text = "Default message") { cout << "Constructor called." << endl; pmessage = new char[strlen(text) + 1]; // Allocate space for text strcpy_s(pmessage, strlen(text)+1, text); // Copy text to new memory } // Copy constructor definition CMessage(const CMessage& aMess) { cout << "Copy constructor called." << endl; size_t len = strlen(aMess.pmessage)+1; pmessage = new char[len]; strcpy_s(pmessage, len, aMess.pmessage); } // Destructor to free memory allocated by new ~CMessage() { cout << "Destructor called." // Just to track what happens << endl; delete[] pmessage; // Free memory assigned to pointer } }; 对 Ex8_05.cpp 版本所做的修改采用突出显示。现在从构造函数和赋值运算符函数的输出,来跟 踪何时调用它们。此类中还有一个复制构造函数和一个 operator+()函数。operator+()函数用来将两个 CMessage 对象加起来。我们可以添加将 CMessage 对象用字符串字面量连接起来的版本,但根据目 前的用途,没必要这么做。通过 CMessage 对象上的一些简单操作,来看看复制时都发生了些什么。 试一试:跟踪对象的复制操作 下面的代码用来练习 CMessage 类: // Ex8_07.cpp // How many copy operations? #include #include using std::cout; using std::endl; // Insert CMessage class definition here... int main() { CMessage motto1("The devil takes care of his own. "); CMessage motto2("If you sup with the devil use a long spoon.\n"); CMessage motto3; cout << " Executing: motto3 = motto1 + motto2 " << endl;; motto3 = motto1 + motto2; cout << " Done!! " << endl << endl; cout << " Executing: motto3 = motto3 + motto1 + motto2 " << endl; motto3 = motto3 + motto1 + motto2; 第 8 章 深入理解类 307 cout << " Done!! " << endl << endl; cout << "motto3 contains - "; motto3.ShowIt(); cout << endl; return 0; } 此示例产生如下输出: Constructor called. Constructor called. Constructor called. Executing: motto3 = motto1 + motto2 Add operator function called. Constructor called. Copy constructor called. Destructor called. Assignment operator function called. Destructor called. Done!! Executing: motto3 = motto3 + motto1 + motto2 Add operator function called. Constructor called. Copy constructor called. Destructor called. Add operator function called. Constructor called. Copy constructor called. Destructor called. Assignment operator function called. Destructor called. Destructor called. Done!! motto3 contains - The devil takes care of his own. If you sup with the devil use a long spoon. The devil takes care of his own. If you sup with the devil use a long spoon. Destructor called. Destructor called. Destructor called. 示例说明 我们感兴趣的第一条语句是: motto3 = motto1 + motto2; // Use new addition operator 这条语句调用 operator+()将 motto1 和 motto2 相加,此运算符函数调用构造函数来创建要返回的 临时对象。然后,复制构造函数复制返回的对象,可以从析构函数调用中看到,析构函数销毁了此 返回对象。然后 operator=()函数将副本复制到 motto3 中。最后,通过调用析构函数,销毁作为赋值 操作右操作数的临时对象。这条语句导致了两个复制临时对象(rvalue)的操作。 Visual C++ 2012 入门经典(第 6 版) 308 我们感兴趣的第二条语句是: motto3 = motto3 + motto1 + motto2; 首先调用 operator+()函数连接 motto3 和 motto1,此运算符函数调用构造函数来创建要返回的对 象。然后使用复制构造函数来复制返回的对象,在析构函数销毁函数中创建的原始对象之后,通过 再一次调用 operator+()函数,连接副本与 motto2,重复执行函数调用序列。最后,调用 operator=() 函数来存储结果。因此对于这条简单的语句,有 3 个临时对象复制操作,两个操作来自复制构造函 数调用,一个操作来自赋值运算符。 如果 CMessage 是一个十分复杂的大对象,那么所有这些复制操作在运行时间方面是非常昂贵 的。如果可以避免这些复制操作,就可以大大地提高执行效率。我们来看看如何能够做到这一点。 8.5.2 应用 rvalue 引用形参 当源对象是一个临时对象,在复制操作之后立即就被销毁时,复制的替代方案是偷用由 pmessage 成员指向的临时对象的内存,并传送到目标对象。如果可以这么做,那么不需要为目标对 象分配更多的内存,也不需要释放源对象拥有的内存。在操作完成之后将立即销毁源对象,因此这 么做没有风险——只是加快了执行速度。实现此技术的关键是检测复制操作中的源对象何时是一个 rvalue。这正是 rvalue 引用形参能做的事情。 可以像下面这样额外创建 operator=()函数的重载: CMessage& operator=(CMessage&& aMess) { cout << "Move assignment operator function called." << endl; delete[] pmessage; // Release memory for left operand pmessage = aMess.pmessage; // Steal string from rhs object aMess.pmessage = nullptr; // Null rhs pointer return *this; // Return a reference to 1st operand } 当右操作数是一个 rvalue,即临时对象时,调用此运算符函数。当右操作数是一个 lvalue 时, 调用具有 lvalue 引用形参的原始函数。函数的 rvalue 引用版本删除目标对象的 pmessage 成员指向的 字符串,并复制源对象的 pmessage 成员中存储的地址。然后将源对象的 pmessage 成员设置为 nullptr。 这么做是必需的,源对象的析构函数调用会删除消息。需要注意的是,在此例中,不能将形参指定 为 const,因为您正在修改它。 添加具有 rvalue 引用形参的复制构造函数的重载,可以将相同的逻辑应用于复制构造函数操作: CMessage(CMessage&& aMess) { cout << "Move copy constructor called." << endl; pmessage = aMess.pmessage; aMess.pmessage = nullptr; } 不是将源对象的消息复制到被构造的对象,取而代之的只是将消息字符串的地址从源对象传输到 新对象,因此在此例中,复制只是一个移动操作。与以前一样,将源对象的 pmessage 设置为 nullptr, 以防止析构函数删除消息字符串。 第 8 章 深入理解类 309 试一试:高效的对象复制操作 创建一个新的控制台应用程序 Ex8_08,并复制 Ex8_07 的代码。然后添加重载的 operator=()函 数,并将刚才讨论的构造函数复制到 CMessage 类定义中。此例子会产生如下输出: Constructor called. Constructor called. Constructor called. Executing: motto3 = motto1 + motto2 Add operator function called. Constructor called. Move copy constructor called. Destructor called. Move assignment operator function called. Destructor called. Done!! Executing: motto3 = motto3 + motto1 + motto2 Add operator function called. Constructor called. Move copy constructor called. Destructor called. Add operator function called. Constructor called. Move copy constructor called. Destructor called. Move assignment operator function called. Destructor called. Destructor called. Done!! motto3 contains - The devil takes care of his own. If you sup with the devil use a long spoon. The devil takes care of his own. If you sup with the devil use a long spoon. Destructor called. Destructor called. Destructor called. 示例说明 从输出结果可以看到,上例中涉及对象复制的所有操作现在都执行为移动操作。与复制构造函 数调用一样,赋值运算符函数调用现在使用的是具有 rvalue 引用形参的版本。输出结果还表明, motto3 字符串的最后结果与以前相同,因此一切工作正常。 对于定义复杂对象的类,用具有 rvalue 引用形参的版本重载赋值运算符和复制构造函数这会大 大地提高性能。 如果在类中定义 operator=()函数和复制构造函数时, 将形参定义为非常量 rvalue 引用, 则需要确保也定义了具有 const lvalue 引用形参的标准版本。否则,编译器会提供它们的 默认版本,逐一成员地进行复制。这肯定不是我们所期望的。 Visual C++ 2012 入门经典(第 6 版) 310 8.5.3 命名的对象是 lvalue 当调用 CMessage 类中具有 rvalue 引用形参的赋值运算符函数时,我们肯定知道实参(即右操作 数)是一个 rvalue,因此,它是一个临时对象,我们可以偷用它的内存。但是,在此运算符函数的函 数体内,形参 aMess 是一个 lvalue。这是因为任何表达式,如果是一个命名的变量,则它是一个 lvalue。 这可能会重新导致效率低下,我们会通过下面这个示例来演示说明这一点,该示例使用 CMessage 类的修改版本。 class CMessage { private: CText text; // Object text string public: // Function to display a message void ShowIt() const { text.ShowIt(); } // Overloaded addition operator CMessage operator+(const CMessage& aMess) const { cout << "CMessage add operator function called." << endl; CMessage message; message.text = text + aMess.text; return message; } // Copy assignment operator for CMessage objects CMessage& operator=(const CMessage& aMess) { cout << "CMessage copy assignment operator function called." << endl; if(this == &aMess) // Check addresses, if equal { text = aMess.text; } return *this; // Return a reference to 1st operand } // Move assignment operator for CMessage objects CMessage& operator=(CMessage&& aMess) { cout << "CMessage move assignment operator function called." << endl; text = aMess.text; return *this; // Return a reference to 1st operand } // Constructor definition CMessage(const char* str = "Default message") { cout << "CMessage constructor called." << endl; text = CText(str); } 第 8 章 深入理解类 311 // Copy constructor definition CMessage(const CMessage& aMess) { cout << "CMessage copy constructor called." << endl; text = aMess.text; } // Move constructor definition CMessage(CMessage&& aMess) { cout << "CMessage move constructor called." << endl; text = aMess.text; } }; 消息文本现在存储为 CText 类型的对象,CMessage 类的成员函数也进行了相应的修改。需要注 意的是,CMessage 类具有 rvalue 引用版本的复制构造函数和赋值运算符,因此在可能的情况下,它 应该是移动而不是创建新对象。下面是 CText 类的定义: class CText { private: char* pText; public: // Function to display text void ShowIt() const { cout << pText << endl; } // Constructor CText(const char* pStr="No text") { cout << "CText constructor called." << endl; size_t len(strlen(pStr)+1); pText = new char[len]; // Allocate space for text strcpy_s(pText, len, pStr); // Copy text to new memory } // Copy constructor definition CText(const CText& txt) { cout << "CText copy constructor called." << endl; size_t len(strlen(txt.pText)+1); pText = new char[len]; strcpy_s(pText, len, txt.pText); } // Move constructor definition CText(CText&& txt) { cout << "CText move constructor called." << endl; pText = txt.pText; txt.pText = nullptr; } // Destructor to free memory allocated by new Visual C++ 2012 入门经典(第 6 版) 312 ~CText() { cout << "CText destructor called." << endl; // Just to track what happens delete[] pText; // Free memory } // Assignment operator for CText objects CText& operator=(const CText& txt) { cout << "CText assignment operator function called." << endl; if(this != &txt) // Check addresses not equal { delete[] pText; // Release memory for 1st operand size_t len(strlen(txt.pText)+1); pText = new char[len]; // Copy 2nd operand string to 1st strcpy_s(this->pText, len, txt.pText); } return *this; // Return a reference to 1st operand } // Move assignment operator for CText objects CText& operator=(CText&& txt) { cout << "CText move assignment operator function called." << endl; delete[] pText; // Release memory for 1st operand pText = txt.pText; txt.pText = nullptr; return *this; // Return a reference to 1st operand } // Overloaded addition operator CText operator+(const CText& txt) const { cout << "CText add operator function called." << endl; size_t len(strlen(pText) + strlen(txt.pText) + 1); CText aText; aText.pText = new char[len]; strcpy_s(aText.pText, len, pText); strcat_s(aText.pText, len, txt.pText); return aText; } }; 看起来似乎有很多代码,这是因为 CText 类具有重载的复制构造函数和赋值运算符版本,并定 义了 operator+()函数。CMessage 类在实现自己的成员函数时使用了它们。同时还用输出语句来跟踪 何时调用了每个函数。下面通过一个示例来练习这些类的用法。 试一试:重新导致效率低下 下面是一个简单的 main()函数,它使用 CMessage 类的复制构造函数和赋值运算符: // Ex8_09.cpp Creeping inefficiencies #include #include using std::cout; 第 8 章 深入理解类 313 using std::endl; // Insert CText class definition here... // Insert CMessage class definition here... int main() { CMessage motto1("The devil takes care of his own. "); CMessage motto2("If you sup with the devil use a long spoon.\n"); cout << endl << " Executing: CMessage motto3(motto1+motto2); " << endl; CMessage motto3(motto1+motto2); cout << " Done!! " << endl << endl << "motto3 contains - "; motto3.ShowIt(); CMessage motto4; cout << endl << " Executing: motto4 = motto3 + motto2; " << endl; motto4 = motto3 + motto2; cout << " Done!! " << endl << endl << "motto4 contains - "; motto4.ShowIt(); cout << endl; return 0; } 示例说明 main()函数中相对很少的语句却产生了很多输出。我们只讨论几个有意思的地方。首先考虑执 行下面这条语句产生的输出: CMessage motto3(motto1+motto2); 输出如下所示: CMessage add operator function called. CText constructor called. CMessage constructor called. CText constructor called. CText move assignment operator function called. CText destructor called. CText add operator function called. CText constructor called. CText move copy constructor called. CText destructor called. CText move assignment operator function called. CText destructor called. CText constructor called. CMessage move constructor called. CText assignment operator function called. CText destructor called. 要了解到底发生了些什么事,需要将输出消息与被调用函数中的代码关联起来。首先调用 CMessage 类的 operator+()函数,将motto1 与motto2 连接起来。在operator+()函数体内,调用 CMessage 构造函数来创建消息对象,在此过程中,调用 CText 构造函数。一切进行得很顺利,直到到达输出 的倒数第二行,即表明调用 CMessage 移动构造函数的那行输出后面。当此构造函数执行时,实参 必须是临时的(即一个 rvalue),因此,函数体中存储 text 成员值的赋值语句应该是 CText 对象的一个 Visual C++ 2012 入门经典(第 6 版) 314 移动赋值操作,而不是复制赋值操作。于是问题就出现了,因为在 CMessage 移动构造函数内,aMess 形参是一个 lvalue(因为它有名称),尽管事实上我们肯定知道传递给函数的实参是一个 rvalue。这就 意味着 aMess.text 也是一个 lvalue。如果不是,则会调用 CMessage 复制构造函数。 下面这条语句也会产生同样的问题: motto4 = motto3 + motto2; 如果看一下这条语句的输出,就会发现当调用 CMessage 对象的移动赋值运算符时会产生完全 相同的问题。当实际上可以移动实参的 text 成员时,却对它进行了复制。 如果有办法在 CMessage 类的移动赋值和移动构造函数中强制 aMess.text 成为一个 rvalue,就 可以修复这些效率低下的问题。C++库中的 utility 头文件特意提供了 std::move()函数,它所完成的工 作完全符合我们的期望。此函数返回作为 rvalue 传递给它的任何实参。可以像下面这样更改 CMessage 移动构造函数: CMessage(CMessage&& aMess) { cout << "CMessage move constructor called." << endl; text = std::move(aMess.text); } 现在,赋值语句右边是一个 rvalue,此语句设置新对象的文本成员,所以调用 CText 类中的移 动赋值运算符函数,而不是复制赋值运算符函数。 可以用相似的方式修改移动赋值运算符函数: CMessage& operator=(CMessage&& aMess) { cout << "CMessage move assignment operator function called." << endl; text = std::move(aMess.text); return *this; // Return a reference to 1st operand } std::move()函数在 utility 头文件中声明,所以需要在此示例中添加它的#include 指令。如果重新编 译程序并再次执行它,则从输出可以看到,已经修改的两个函数调用了 CText 移动赋值运算符函数。 在类中实现构造函数和赋值运算符函数以便它们使用 std::move()函数被称为移动语义。 8.6 默认的类成员 请记住,编译器能为类默认提供一切所需要的。假定定义了如下类: class MyClass { int data; }; 这个类只定义了一个数据成员,似乎可做的工作很少,但编译器提供了一些成员。如果没有指 定,编译器将提供如下成员的定义: ● 默认的构造函数: MyClass(){} 第 8 章 深入理解类 315 ● 执行逐个成员复制操作的复制构造函数: MyClass(const MyClass& obj) {/* Copy members */} ● 析构函数定义如下: ~MyClass(){} ● 执行逐个成员复制操作的默认赋值操作符: MyClass& operator=(const MyClass& obj) {/* Copy members */} 如果定义了构造函数,编译器就不提供默认构造函数。如果不希望其他的默认操作受影响,就 必须在类中把它们定义为私有,这样就不能从类的外部访问到它们。 8.7 类模板 第 6 章讲到,可以定义函数模板,以自动生成在实参类型或返回值类型方面不同的函数。C++具 有适用于类的类似机制。类模板本身不是类,而只是编译器用来生成类代码的一种方法。从图 8-5 可 以看出,类模板如同函数模板一样,我们也是通过指定模板中尖括号内的形参类型(本例中是 T)来确 定希望生成的类。以这种方式生成的类称作类模板的实例,根据模板创建类的过程称为实例化模板。 将 T 指定为 int 将 T 指定为 double 将 T 指定为 CBox 使用模板中 T 处的 类型创建 CBox 类 类实例 类模板 T 是一个形参,同类型的实参值被提供 给该形参。 指定的每个不同类型的值实参都创建 一个新类。 图 8-5 以特定的类型实例化模板类的某个对象时,编译器将生成适当的类定义,因此一个类模板可以 生成任意数量、各不相同的类。通过一个示例,我们将能够充分地理解类模板的实际工作过程。 警告: Visual C++没有实现自动定义的移动构造函数和移动赋值操作符, 这与 C++ 11 语言标准不一致。 Visual C++ 2012 入门经典(第 6 版) 316 8.7.1 定义类模板 我们将选择一个简单的示例来说明如何定义和使用类模板,并且不过多考虑因误用而可能出现 的错误,那样将使问题复杂化。假设要定义几个可以存储大量数据样本的类,每个类都应当提供一 个求出所存储样本中最大值的Max()函数。该函数类似于在第6章讨论函数模板时介绍的Max()函数。 可以定义一个类模板,用来生成可以存储任何类型样本的 CSamples 类。 template class CSamples { public: // Constructor definition to accept an array of samples CSamples(const T values[], int count) { m_Free = count < maxSamples ? Count : maxSamples; // Don't exceed the array for(int i = 0; i < m_Free; i++) m_Values[i] = values[i]; // Store count number of samples } // Constructor to accept a single sample CSamples(const T& value) { m_Values[0] = value; // Store the sample m_Free = 1; // Next is free } // Default constructor CSamples() : { m_Free = 0 } // Nothing stored, so first is free // Function to add a sample bool Add(const T& value) { bool OK = m_Free < 100; // Indicates there is a free place if(OK) m_Values[m_Free++] = value; // OK true, so store the value return OK; } // Function to obtain maximum sample T Max() const { // Set first sample or 0 as maximum T theMax = m_Values[0]; for(int i = 1; i < m_Free; i++) // Check all the samples if(m_Values[i] > theMax) theMax = m_Values[i]; // Store any larger sample return theMax; } private: static const size_t maxSamples = 100; // Maximum number of sample T m_Values[maxSamples]; // Array to store samples int m_Free; // Index of free location in m_Values }; 第 8 章 深入理解类 317 为了指出正在定义的是模板而非简单的类,在关键字 class 和类名 CSamples 之前,插入关键字 template 和尖括号内的类型形参 T。该语法实质上与第 6 章定义函数模板的语法相同。形参 T 是类 型变量,它将在声明类对象时由具体类型代替。类定义中出现形参 T 的任何位置,都将由对象声明 中指定的类型代替,这将创建一个对应于指定类型的类定义。可以指定任何类型(基本数据类型或类 类型),但指定的类型必须在类模板的上下文中有意义。任何用来根据模板实例化某个类的类类型, 都必须已经定义过模板的成员函数处理本类对象时要使用的所有运算符。例如,如果类没有实现 operator>(),则不能使用上面的 CSamples 类模板。一般来说,如果需要的话,则可以在类模板中指 定多个形参,稍后再来讨论这种可能性。 回到本示例上来,存储样本的数组的类型指定为 T。因此,该数组将成为声明 CSamples 对象时 为 T 指定的那种类型的数组。可以看出,不仅在 Add()和 Max()函数中,而且还在类的两个构造函数 中也使用了类型 T。当使用该模板实例化类对象时,同样替换掉构造函数中出现的这些 T。 构造函数支持创建空对象、只有一个样本的对象以及用样本数组进行初始化的对象。Add() 函数允许一次一个地在对象中添加样本。也可以重载这个函数,以允许添加样本数组。在 Add() 函数中和在接受样本数组的构造函数中,类模板提供了基本的措施来防止超过 m_Values 数组的最 大容量。 如前所述,理论上可以创建可处理任何数据类型的 CSamples 类的对象:int 类型、double 类型、 CBox 类型或任何已经定义过的类类型。在实践中,这种可能性并不意味着必定能够编译所创建的 对象,且像我们预期的那样工作。实际情况完全取决于模板定义所做的事情,通常一个模板仅适用 于特定的类型范围。例如,Max()函数隐含地认为>运算符可以用于被处理的任何类型。如果实际情 况不是这样,则不能编译程序。无疑,通常定义的模板只是为了处理某些类型而非此外的其他类型, 但无法限制应用到模板上的类型。 模板成员函数 我们或许希望将类模板成员函数的定义放在模板定义的外部。实现该功能的语句不是特别明显, 因此我们来看一下应该如何做。以常规方式将函数声明放在类模板定义的内部。例如: template class CSamples { // Rest of the template definition... T Max() const; // Function to obtain maximum sample // Rest of the template definition... } 此处的代码将 Max()函数声明为类模板的成员,但没有定义该函数。现在,需要为这个成员函 数的定义创建单独的函数模板,创建时必须使用模板类的名称加上尖括号内的形参,以标识函数模 板所属的类模板: template T CSamples ::Max() const { // Set first sample as maximum T theMax = m_Values[0]; Visual C++ 2012 入门经典(第 6 版) 318 for(int i = 1; i < m_Free; i++) // Check all the samples if(m_Values[i] > theMax) theMax = m_Values[i]; // Store any larger sample return theMax; } 看看第 6 章学过的函数模板语法。因为该函数模板是形参为 T 的类模板的成员,所以这里的函 数模板定义应该有与类模板定义相同的形参。本例中只有一个形参 T,但通常可能有好几个。如果 类模板有两个或更多形参,则每个定义成员函数的模板也应该有同样多的形参。 注意,作用域解析运算符之前只能使用附带形参名称 T 的类模板名。这是必需的—— 形参对于 识别出根据该模板生成的函数属于哪个类非常重要。类模板的类型是 CSamples,其中 T 是在创 建类模板实例时指定的类型。在类模板中插入指定的类型,从而生成类定义。还将其插入函数模板 中,从而生成本类中 Max()函数的定义。每个根据类模板生成的类都需要有自己的 Max()函数定义。 在类模板定义外部定义构造函数或析构函数与此类似。可以将接受样本数组的构造函数的定义 写成下面的形式: template CSamples ::CSamples(const T values[], int count) { m_Free = count < maxSamples ? count : maxSamples; // Don't exceed the array for(int i = 0; i < m_Free; i++) m_Values[i] = values[i]; // Store count number of samples } 我们以定义普通成员函数时使用的相同方式,在模板中指定构造函数属于哪个类。注意,构造 函数名不要求形参说明—— 它只能是 CSamples,但需要用类模板类型 CSamples加以限定。在作 用域解析运算符之前只能使用附带形参名称的类模板名。 8.7.2 根据类模板创建对象 当使用函数模板定义的函数时,编译器能够根据函数实参的类型推断出模板类型实参。函数模 板的类型形参是使用特定的函数隐式确定的。类模板有些不同。为了以类模板为基础创建对象,必 须在声明中指定类名后面的类型形参。 例如,为了声明一个 CSamples< >对象来处理 double 类型的样本,需要将声明写成下面这样: CSamples myData(10.0); 该语句定义了一个 CSamples类型的对象,它可以存储 double 类型的样本。该对象是用 值为 10.0 的一个样本创建的。 如果在类定义的外部已经实现了成员函数, 它们就必须放在定义类模板的头文件 中;它们不能放在 .cpp 文件中, 因为在使用它们时编译器需要找到模板函数的完整实 现代码。 第 8 章 深入理解类 319 试一试:使用类模板 可以根据 CSamples< >模板,创建一个存储 CBox 对象的对象。这是没有问题的,因为 CBox 类实 现了重载大于运算符的 operator>()函数。利用下面代码中给出的 main()函数,我们可以练习类模板的 用法: // Ex8_10.cpp // Using a class template #include #include // For operator overload templates using std::cout; using std::endl; using namespace std::rel_ops; // Put the CBox class definition from Ex8_06.cpp here... // CSamples class template definition template class CSamples { public: // Constructors CSamples(const T values[], int count); CSamples(const T& value); CSamples(T&& value); CSamples() : m_Free(0) { } bool Add(const T& value); // Insert a value bool Add(T&& value); // Insert a value with move semantics T Max() const; // Calculate maximum private: static const size_t maxSamples = 100; // Maximum number od sample T m_Values[maxSamples]; // Array to store samples int m_Free; // Index of free location in m_Values }; // Constructor template definition to accept an array of samples template CSamples::CSamples(const T values[], int count) { m_Free = count < 100 ? count : 100; // Don't exceed the array for(int i = 0; i < m_Free; i++) m_Values[i] = values[i]; // Store count number of samples } // Constructor to accept a single sample template CSamples::CSamples(T& value) { m_Values[0] = value; // Store the sample m_Free = 1; // Next is free } // Constructor to accept a temporary sample template CSamples::CSamples(T&& value) { cout << "Move constructor." << endl; m_Values[0] = std::move(value); // Store the sample Visual C++ 2012 入门经典(第 6 版) 320 m_Free = 1; // Next is free } // Function to add a sample template bool CSamples::Add(const T& value) { cout << "Add." << endl; bool OK = m_Free < 100; // Indicates there is a free place if(OK) m_Values[m_Free++] = value; // OK true, so store the value return OK; } template bool CSamples::Add(T&& value) { cout << "Add move." << endl; bool OK = m_Free < 100; // Indicates there is a free place if(OK) m_Values[m_Free++] = std::move(value); // OK true, so store the value return OK; } // Function to obtain maximum sample template T CSamples::Max() const { T theMax = m_Values[0]; // Set first sample as maximum for(int i = 1; i < m_Free; i++) // Check all the samples if(theMax < m_Values[i]) theMax = m_Values[i]; // Store any larger sample return theMax; } int main() { CBox boxes[] = { // Create an array of boxes CBox(8.0, 5.0, 2.0), // Initialize the boxes... CBox(5.0, 4.0, 6.0), CBox(4.0, 3.0, 3.0) }; // Create the CSamples object to hold CBox objects CSamples myBoxes(boxes, _countof(boxes)); CBox maxBox = myBoxes.Max(); // Get the biggest box cout << endl // and output its volume << "The biggest box has a volume of " << maxBox.Volume() << endl << endl; CSamples moreBoxes(CBox(8.0, 5.0, 2.0)); moreBoxes.Add(CBox(5.0, 4.0, 6.0)); moreBoxes.Add(CBox(4.0, 3.0, 3.0)); cout << "The biggest box has a volume of " << moreBoxes.Max().Volume() << endl; return 0; } 应该用 Ex8_06.cpp 中 CBox 类的定义代替该程序开头相应的注释。除默认构造函数以外,该模 第 8 章 深入理解类 321 板的所有成员函数都是通过单独的函数模板定义的,本例只是为了给出一个说明类模板用法的完整 示例。CSamples 类模板还包括一个构造函数和带移动语义的 Add()函数来说明它们的执行过程。 在 main()函数中,创建了一个包含 3 个 CBox 对象的数组,然后使用该数组初始化一个可以存 储 CBox 对象的 CSamples 对象。CSamples 对象的声明基本上与普通类对象的声明相同,但在模板 类名称后面增加了以尖括号包围的模板类型形参。 接着用不同的方式创建 moreBoxes 对象。这次调用了构造函数和带移动语义的 Add()函数,因 为实参是 rvalue。 该程序产生下面的输出: The biggest box has a volume of 120 Move constructor. Add move. Add move. The biggest box has a volume of 120 注意,当创建类模板的实例时,不能理解成用于创建函数成员的那些函数模板的实例也被创建。 编译器只创建程序中实际调用的那些成员函数的模板实例。事实上,函数模板甚至可以包含编码错 误,而且只要不调用该模板生成的成员函数,编译器就不会报错。我们可以利用该示例证实这一点。 试着给非移动的 Add()成员的模板引入几处错误。该程序仍然能够编译和运行,因为它没有调用该 Add()函数。 可以尝试修改上面的示例,看看用不同类型的模板实例化类时会发生什么事情。 8.7.3 使用有多个形参的类模板 要在类模板中使用多个类型形参,只需简单地扩展刚才看到的使用单个形参的示例即可。可以 在模板定义中的任何位置使用各个类型形参。例如,可以定义一个使用两个类型形参的类模板: template class CExampleClass { // Class data members private: T1 m_Value1; T2 m_Value2; // Rest of the template definition... }; 注意:如果给类的构造函数添加一些输出语句,其结果会令人惊讶。CBox 的构 造函数被调用了 103 次!看一看在 main()函数中究竟发生了什么。首先创建了一个包 含 3 个 CBox 对象的数组,因此发生了 3 次调用。然后创建了一个容纳这些对象的 CSamples 对象,但 CSamples 对象包含的数组有 100 个 CBox 类型的变量, 因此需要 再调用默认构造函数 100 次,每个数组元素需要一次。当然, maxBox 对象将由编译 器提供的默认复制构造函数创建。 Visual C++ 2012 入门经典(第 6 版) 322 上面那两个类数据成员的类型取决于初始化类模板的对象时为形参提供的类型。可以显式实例 化模板而无须定义任何对象。例如,在 CSamples 模板的定义后面可以编写如下语句: template class CSamples; 这个语句显式实例化了 Foo 类型的 CSamples,但它没有实例化任何对象。如果 Foo 没有实现模 板所需要的所有成员函数,那么编译器在进行上述显式实例化时将生成一个错误消息,就好像在定 义这种类型的对象那样生成错误消息。 类模板中形参的类型不受限制。还可以在类定义中使用一些需要以常量或常量表达式进行替换 的形参。在前面的 CSamples 模板中,我们随意将数组 m_Values 定义成包含 100 个元素。然而,还 可以让该模板的用户在实例化对象时选择数组的大小,方法是将该模板定义成如下形式: template class CSamples { private: T m_Values[Size]; // Array to store samples int m_Free; // Index of free location in m_Values public: // Constructor definition to accept an array of samples CSamples(const T values[], int count) { m_Free = count < Size ? count : Size; // Don't exceed the array for(int i = 0; i < m_Free; i++) m_Values[i] = values[i]; // Store count number of samples } // Constructor to accept a single sample CSamples(const T& value) { m_Values[0] = value; // Store the sample m_Free = 1; // Next is free } CSamples() : m_Free(0) {} // Default constructor // Function to add a sample int Add(const T& value) { int OK = m_Free < Size; // Indicates there is a free place if(OK) m_Values[m_Free++] = value; // OK true, so store the value return OK; } // Function to obtain maximum sample T Max() const { // Set first sample or 0 as maximum T theMax = m_Values[0]; for(int i = 1; i < m_Free; i++) // Check all the samples if(m_Values[i] > theMax) theMax = m_Values[i]; // Store any larger sample 第 8 章 深入理解类 323 return theMax; } }; 创建对象时给 Size 提供的数值将代替整个模板定义中该形参的所有实例。现在,可以像下面这 样声明前面示例中的 CSamples 对象: CSamples myBoxes(boxes, _countof(boxes));); 因为可以为 Size 形参提供任何常量表达式,所以还可以这样写: CSamples myBoxes(boxes, _countof(boxes))); 不过,该示例的这种模板用法不太好,原来的版本要灵活得多。使 Size 成为模板形参的结果是, 那些存储相同类型的对象但 Size 形参值不同的模板实例是完全不同的类,而且不能混用。例如, CSamples类型的对象不能在包含 CSamples类型对象的表达式中使用。 当实例化模板时,需要小心处理包含比较运算符的表达式。看看下面的语句: CBox myBoxes[] = {CBox(1,2,3), CBox(2,3,4),CBox(4,5,6), CBox(5,7,8)}; CSamples 3 ? 3 : 2 > mySamples(myBoxes,4);// Wrong! const int x = 2, y = 1; CSamples y ? 10 : 20 > MyType(); // Wrong! 该语句不能正确编译,因为表达式中_countof(myBoxes)前面的>解释为右尖括号。应该将这条 语句写成: CSamples 3 ? 3 : 2) > mySamples(myBoxes,4); 括号确保先计算第二个模板实参的表达式,且不会与尖括号混淆。 8.7.4 函数对象模板 定义函数对象的类一般是由模板来定义的,这么做的理由很明显,这样定义的函数对象能够使 用各种实参类型。下面是之前看到过的 Area 类的模板: template class Area { public: T operator()(const T length, const T width){ return length*width; } }; 此模板允许定义函数对象来计算任何数值类型尺寸的面积。可以将以前看到的 printArea()函数 定义为函数模板: template void printArea(const T length, const T width, Area area) { cout << "Area is " << area(length, width); } 现在,可以像下面这样调用 printArea()函数: printArea(1.5, 2.5, Area ()); printArea(100, 50, Area ()); 函数对象广泛应用于标准模板库,这将在第 10 章学习,因此,我们将会看到在那种环境下有关 函数对象用法的一些实用示例。 Visual C++ 2012 入门经典(第 6 版) 324 8.8 完美转发 完美转发是一个重要的概念,因为它能显著提升大对象的性能,这些大对象会花很多时间进行 复制或创建。完美转发仅与类模板或函数模板的环境相关。初看起来它似乎有点复杂,但一旦掌握 了这个概念,它就会变得非常简单。那么什么是完美转发呢? 假定有一个函数 fun1(),它用一个类类型 T 来参数化。这可以用一个函数模板来定义,或者在 类模板中定义。再假定将 fun1()定义为带一个 T&&类型的 rvalue 引用形参。如第 6 章所述,fun1() 可以用一个 lvalue、lvalue 引用或 rvalue 实参来调用。Lvalue 或 lvalue 引用实参会使 fun1()模板实例 有一个 lvalue 引用形参,否则它就有一个 rvalue 引用形参。 接着假定 fun1()调用了另一个函数 fun2(),fun2()有两个版本,一个版本有一个 lvalue 引用形参, 另一个版本有一个 rvalue 引用形参。fun1()把它接收的实参传送给 fun2()。理想情况下,当 fun1()接 收的实参是一个 rvalue 引用时,它就应调用有 rvalue 引用形参的 fun2()版本,这样初始参数就不会 移动或复制。当实例化 fun1()并用 lvalue 或 lvalue 引用实参调用时,它就应调用有 lvalue 引用形参的 fun2()版本。换言之,fun1()的实参要进行完美转发使代码总保持最佳性能。 当实参是 rvalue 时,需要将 fun1()中的形参引用从 lvalue 转换为 rvalue 的方式,这样就可以把 它作为 rvalue 传送给 fun2()。于是该实参就不会复制或移动。fun1()的实参是 lvalue 或 lvalue 引用时, 它就应保持不变,就像在对 fun2()的调用中一样。这就是 utility 头文件中声明的 std::forward()函数模 板的作用。如果给它传送一个 rvalue 引用实参,它就把该实参返回为 rvalue。如果给它传送一个 lvalue 引用,它就把该实参返回为一个 lvalue 引用。下面看看其工作方式。 试一试:完美转发 这个例子使用本章末尾详细讨论的 string 类。除了演示完美转发之外,该例子还演示了如何把 模板函数定义为非模板类的一个成员,以及如何使用带两个类型形参的模板。该示例定义的 Person 类如下所示: class Person { public: // Constructor template template Person(T1&& first, T2&& second) : firstname(forward(first)), secondname(forward(second)) {} // firstname(first), secondname(second) {} string getName() const { return firstname.getName() + " " + secondname.getName(); } private: Name firstname; Name secondname; }; 这是一个普通的类,其构造函数由一个带两个类型形参 T1 和 T2 的模板来定义,这样构造函数 第 8 章 深入理解类 325 的实参就可以是不同的类型,例如 string 类型和 char*类型。存储一个人的姓名的数据成员是 Name 类型,这将稍后定义。Person 构造函数用实参初始化数据成员之前,把它们传送给 utility 头文件中 的 std::forward()。这将确保 rvalue 引用实参用于初始化 Person 类的数据成员时仍是 rvalue 引用。后 面会注释掉这一行代码。getName()成员返回 Person 对象的字符串表示。 下面是 Name 类的定义: class Name { public: Name(string& aName) : name(aName) { cout << "Lvalue Name constructor." << endl; } Name(string&& aName) : name(move(aName)) { cout << "Rvalue Name constructor." << endl; } const string& getName() const { return name; } private: string name; }; 这个类把一个名字封装为一个 string 对象。string 对象可以从以空字符结尾的字符串或另一个 string 对象中创建。该类有两个构造函数,一个带 lvalue 引用实参,另一个带 rvalue 引用实参,后者 仅在实参是 rvalue 引用时调用。移动 rvalue 引用实参的原因是 string 类支持移动语义。 使用这些类的程序如下: // Ex8_11.cpp // Perfect forwarding #include #include #include using std::string; using std::cout; using std::endl; using std::forward; using std::move; // Put the Name class definition here... // Put the Person class definition here... int main() { cout << "Creating Person(string(\"Ivor\") , string(\"Horton\")) - rvalue arguments:" << endl; Person me(string("Ivor") , string("Horton")); cout << "Person is " << me.getName() << endl << endl; string first("Fred"); string second("Fernackerpan"); Visual C++ 2012 入门经典(第 6 版) 326 cout << "Creating Person(first , second) - lvalue arguments:" << endl; Person other(first,second); cout << "Person is " << other.getName() << endl << endl; cout << "Creating Person(first , string(\"Bloggs\")) - lvalue, rvalue arguments:" << endl; Person brother(first , string("Bloggs")); cout << "Person is " << brother.getName() << endl << endl; cout << "Creating Person(\"Richard\" , \"Horton\")) - rvalue const char* arguments:" << endl; Person another("Richard", "Horton"); cout << "Person is " << another.getName() << endl << endl; return 0; } 这个例子的输出如下: Creating Person(string("Ivor") , string("Horton")) - rvalue arguments: Rvalue Name constructor. Rvalue Name constructor. Person is Ivor Horton Creating Person(first , second) - lvalue arguments: Lvalue Name constructor. Lvalue Name constructor. Person is Fred Fernackerpan Creating Person(first , string("Bloggs")) - lvalue, rvalue arguments: Lvalue Name constructor. Rvalue Name constructor. Person is Fred Bloggs Creating Person("Richard" , "Horton") - rvalue const char* arguments: Rvalue Name constructor. Rvalue Name constructor. Person is Richard Horton 示例说明 从输出结果可以看出,当 Person 构造函数的对应实参是一个 lvalue 引用时,就调用带 lvalue 引 用形参的 Name 构造函数;当 Person 构造函数的实参是一个 rvalue 时,就调用带 rvalue 引用形参的 Name 构造函数。显然,std::forward()函数的工作方式与期望的相同。 现在,在 Person 类中去掉加了注释符号的代码行中的注释符号,并给其上面的一行代码加上注 释符号。现在构造函数的实参不再转发,其输出是: Creating Person(string("Ivor") , string("Horton")) - rvalue arguments: Lvalue Name constructor. Lvalue Name constructor. Person is Ivor Horton Creating Person(first , second) - lvalue arguments: Lvalue Name constructor. Lvalue Name constructor. 第 8 章 深入理解类 327 Person is Fred Fernackerpan Creating Person(first , string("Bloggs")) - lvalue, rvalue arguments: Lvalue Name constructor. Lvalue Name constructor. Person is Fred Bloggs Creating Person("Richard" , "Horton") - rvalue const char* arguments: Rvalue Name constructor. Rvalue Name constructor. Person is Richard Horton 这与没有应用完美转发功能的情况相一致,但最后一个 Person 对象除外。如不使用完美转发, 如何调用带 rvalue 引用形参的 Name 构造函数?答案是:字面量保持 rvalue。如果允许把字面量实 参表示为 lvalue 就可以修改它,这与字面量的定义相悖。 在 Person 类中为构造函数使用模板有一个重要的方面,它允许从两个实参中创建一个 Person 对象,这两个实参可以是临时的 string 对象、lvalue 字符串对象,以空字符结尾的临时字符串,或以 空字符结尾的 lvalue 字符串。为了提供这两个实参而不使用模板,需要编写 16 个类构造函数。而使 用模板源代码会短得多,如果使用的构造函数实参组合少于 16 个,可执行的模块就较小。 8.9 使用类 我们已经接触到大多数定义本地 C++类的基本内容,因此应该看一看如何使用类来解决问题。为 了使本书的篇幅适中,需要使问题尽量简单,因此下面将考虑几个可以使用扩展版 CBox 类的问题。 8.9.1 类接口的概念 扩展 CBox 类的实现应该引入类接口的概念。我们打算给任何想处理 CBox 对象的人员提供一 个工具箱,因此需要汇总一套表示箱子接口的函数。因为接口是处理 CBox 对象的唯一方法,所以 被定义的接口应当充分覆盖到那些人们有可能对 CBox 对象做的事情,而且应该尽量以防止误用或 偶然性错误的方式实现。 在设计类方面,首先需要考虑打算解决的问题的本质,并由此确定应该在类接口中提供哪些功能。 8.9.2 定义问题 箱子的首要功能是包含这种或那种对象,因此简言之:这是个包装问题。我们将尝试提供一个 大体上使包装工作更容易的类,然后看一看如何使用它。假设人们总是将 CBox 对象打包到其他 CBox 对象中,因为如果我们希望将糖果包装在箱子中,那么总能将每块糖果表示成理想化的 CBox 对象。我们希望在 CBox 类中提供的基本操作包括: ● 计算 CBox 的体积。体积是 CBox 对象的基本特性,我们已经有实现该功能的函数。 ● 比较两个 CBox 对象的体积,以确定哪个更大。应该为 CBox 对象实现一套完整的比较运 算符。 ● 比较 CBox 对象的体积与指定的值,反之亦然。 ● 将两个 CBox 对象相加,将产生包含原来两个对象的 CBox 对象。因此,结果至少将是原来 两个体积的和,也可能更大。 Visual C++ 2012 入门经典(第 6 版) 328 ● 使 CBox 对象乘以一个整数(反之亦然),以提供一个新的 CBox 对象来包含指定数量的原对 象。这实际上是在设计一个纸板箱。 ● 确定有多少个给定尺寸的 CBox 对象可以放入另一个给定尺寸的 CBox 对象。该功能实际上 是除法问题,因此可以通过重载/运算符来实现。 ● 确定放入最大数量给定尺寸的 CBox 对象之后,CBox 对象中剩余的空间。 我们最好就此打住!无疑还有其他可能非常有用的功能,但为了节省篇幅,我们将只考虑完成 上面这些功能,不考虑诸如访问尺寸之类的附属功能。 8.9.3 实现 CBox 类 我们实际上需要考虑希望嵌入 CBox 类内部的错误防护程度。为说明类的各个方面而定义的基 本的 CBox 类可以作为一个始点,但还应该更深入地考虑其他几点。构造函数不够完善,因为不能 确保 CBox 对象的尺寸有效,因此可能首先应该确保始终获得有效的对象。为此,可以重新定义基 本的 CBox 类: class CBox // Class definition at global scope { public: // Constructor definition explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0): m_Height(hv) { if(lv < 0.0 || wv < 0.0 || hv < 0.0) throw "Negative dimension specified for CBox object."; m_Length = std::max(lv, wv); m_Width = std::min(lv, wv); // Length is now greater than or equal to width if(m_Height > m_Length) { m_Height = m_Length; m_Length = hv; // m_Height is still greater than m_Width so swap them double temp = m_Width; m_Width = m_Height; m_Height = temp; } else if( m_Height > m_Width) { m_Height = m_Width; m_Width = hv; } } // Function to calculate the volume of a box double Volume() const { return m_Length*m_Width*m_Height; } // Function providing the length of a box double GetLength() const { return m_Length; } 第 8 章 深入理解类 329 // Function providing the width of a box double GetWidth() const { return m_Width; } // Function providing the height of a box double GetHeight() const { return m_Height; } private: double m_Length; // Length of a box in inches double m_Width; // Width of a box in inches double m_Height; // Height of a box in inches }; 构造函数现在是可靠的,因为在构造函数中将任何被用户设置成小于 0 的尺寸都会抛出一个 异常。 该类的默认复制构造函数和默认赋值运算符是令人满意的,因为我们不需要给数据成员动态分 配内存。在该情况下,默认析构函数同样工作得很好,因此不需要定义它。现在,应该考虑要支持 类对象的比较功能都需要做些什么。 1. 比较 CBox 对象 我们应该包括对>、>=、==、<和<=运算符的支持,使它们能够处理两个操作数都是 CBox 对象 的情况,还能处理一个操作数是 CBox 对象、另一个操作数是 double 类型数值的情况。这样总共有 18 个运算符函数。一旦定义了<和==运算符函数,就可以从 utility 头文件的模板中得到 4 个带双操 作数的运算符函数,这些都在 Ex8_06 中完成了: // Operator function for < comparing CBox objects bool operator<(const CBox& aBox) const { return this->Volume() < aBox.Volume(); } // Operator function for == comparing CBox objects bool operator==(const CBox& aBox) const { return this->Volume() == aBox.Volume(); } 比较左操作数是 CBox 对象、右操作数是一个常量的所有 4 个运算符函数可以放在类中: // Function for testing if a CBox object is > a value bool operator>(const double& value) const { return Volume() > value; } // Function for testing if a CBox object is < a value bool operator<(const double& value) const { return Volume() < value; } // Function for testing if a CBox object is >= a value bool operator>=(const double& value) const { return Volume() >= value; } // Function for testing if a CBox object is <= a value bool operator<=(const double& value) const { return Volume() <= value; } // Function for testing if a CBox object is == a value bool operator==(const double& value) const { return Volume() == value; } Visual C++ 2012 入门经典(第 6 版) 330 // Function for testing if a CBox object is != a value bool operator!=(const double& value) const { return Volume() != value; } 这些默认定义为内联函数。 必须在类外部把左操作数是 double 类型的运算符函数定义为普通函数: // Function for testing if a value is > a CBox object inline bool operator>(const double& value, const CBox& aBox) { return value > aBox.Volume(); } // Function for testing if a value is < CBox object inline bool operator<(const double& value, const CBox& aBox) { return value < aBox.Volume(); } // Function for testing if a value is >= a CBox object inline bool operator>=(const double& value, const CBox& aBox) { return value >= aBox.Volume(); } // Function for testing if a value is <= CBox object inline bool operator<=(const double& value, const CBox& aBox) { return value <= aBox.Volume(); } // Function for testing if a value is == a CBox object inline bool operator==(const double& value, const CBox& aBox) { return value == aBox.Volume(); } // Function for testing if a value is != CBox object inline bool operator!=(const double& value, const CBox& aBox) { return value != aBox.Volume(); } 现在,我们已经得到一套完整的处理 CBox 对象的比较运算符。记住,这些运算符同样可以处 理表达式,只要表达式最终产生类型正确的对象就行。因此,必须能够使用其他重载运算符来合并 表达式。 2. 合并 CBox 对象 现在需要解决的问题是重载+、*、/和%运算符,下面依次实现它们。Ex8_06.cpp 中的加法操作 具有下面的原型: CBox operator+(const CBox& aBox); // Function adding two CBox objects 虽然原来实现的加法运算符并不是理想的解决方案,但还是要继续使用,以避免使 CBox 类过 于复杂。最好是设计一个程序来检查两个操作数是否有尺寸相同的面,如果有,则顺着这样的面进 行连接,但编码工作会很麻烦。当然,如果这是个实际的应用程序,那么可以稍后再来开发更好的 加法操作,并替换现有的版本,而使用原来版本编写的任何程序都无需修改就能继续运行。类的接 口与实现相分离,对于有效的 C++编程具有决定性意义。 注意,本书不再详述减法运算符。这是个明智的决定,可以避免实现过程中固有的复杂性。如 果您实在对此充满热情,而且觉得实现该运算符切合实际,那么可以试一下,但需要决定当得到的 体积结果为负数时应该怎么办。如果允许体积为负数,则需要解决哪些箱子的尺寸可以是负数,以 及在随后的操作中如何处理这类箱子等问题。更简单的概念可能是 CBox 对象相减得到一个体积。 第 8 章 深入理解类 331 乘法操作非常容易,只是创建一个可包含 n 个箱子的箱子而已,其中 n 是乘数。最简单的解决 方案是保持对象的 m_Length 和 m_Width 不变,然后使高度乘以 n,从而得到新的 CBox 对象。我们 可以使该函数更智能化一些,即检查一下乘数是不是偶数,如果是,则使 m_Width 加倍,这样箱子 将并排堆放,然后只需要使 m_Height 乘以 n 的 1/2 即可。该机制如图 8-6 所示,CBox 对象是 aBox 乘以 3 和 6 的结果。 CBox Multiply:n 为奇数 :3×aBox CBox Multiply:n 为偶数 :6×aBox 图 8-6 当然,不需要检查新对象的长度和宽度哪个更大,因为构造函数会自动将较大的值挑出来。可 以将 operator*()函数编写成左操作数是 CBox 对象的成员函数: // CBox multiply operator this*n CBox operator*(int n) const { if(n % 2) return CBox(m_Length, m_Width, n*m_Height); // n odd else return CBox(m_Length, 2.0*m_Width, (n/2)*m_Height); // n even } 在这里,使用%运算符判断 n 是偶数还是奇数。如果是奇数,则 n % 2 的值是 1,if 语句为 true。 如果是偶数,则 n % 2 的值是 0,if 语句为 false。 现在即可使用刚才编写的函数,实现左操作数是整数的版本。可以将该函数编写成普通的非成 Visual C++ 2012 入门经典(第 6 版) 332 员函数: // CBox multiply operator n*aBox CBox operator*(int n, const CBox& aBox) { return aBox*n; } 该版本的乘法操作仅仅将操作数的顺序颠倒了一下,这样就可以直接使用前面的乘法运算符版 本。至此,为 CBox 对象定义的算术运算符就全部完成了。最后可以看看如何实现两个分解运算符 函数 operator/()和 operator%()。 3. 分解 CBox 对象 除法操作确定左操作数指定的 CBox 对象可以包含多少个右操作数指定的 CBox 对象。为了使问 题相对简单,假设所有 CBox 对象都是以正常层序包装的 —— 即高度是垂直的,另外假设它们都是以 相同的朝向包装的 —— 即长度方向相同。如果没有这些假设,问题可能变得相当复杂。 这样,该问题实际上就变为求出一层可以放多少个右操作数对象,再求出左操作数 CBox 对象 中可以放几层。 可以将该运算符编写成下面这样的成员函数: int operator/(const CBox& aBox) const { // Number of boxes in horizontal plane this way int tc1 = static_cast((m_Length / aBox.m_Length))* static_cast((m_Width / aBox.m_Width)); // Number of boxes in horizontal plane that way int tc2 = static_cast((m_Length / aBox.m_Width))* static_cast((m_Width / aBox.m_Length)); //Return best fit return static_cast((m_Height/aBox.m_Height)*(tc1 > tc2 ? tc1 : tc2)); } 该函数首先求出左、右操作数 CBox 对象的长度方向相同时,一层可以容纳多少个右操作数 CBox 对象,并将结果存入 tc1。然后再求出右操作数 CBox 的长度与左操作数 CBox 的宽度同向时, 一层可以容纳多少个右操作数对象。最后,使 tc1 和 tc2 中较大的数乘以可以包装的层数,并返回得 到的值。该过程如图 8-7 所示。 aBox/bBox 的计算过程 图 8-7 第 8 章 深入理解类 333 结果为 16 用这种排列方式可以 存放 12 个 bBox 对象 图 8-7 (续) 有两种将 bBox 放入 aBox 的可能性:一是 bBox 的长度与 aBox 的长度同向,二是 bBox 的长度 与 aBox 的宽度同向。从图 8-7 可以看出,最好的包装结果是旋转 bBox,用 aBox 的长度除以 bBox 的宽度。 另一个用于获得已包装好的 aBox 中剩余空间的分解运算符函数 operator%()更加简单,因为可 以使用刚才编写的运算符来实现。可以将其编写成普通的全局函数,因为该函数不需要访问 CBox 类的私有成员。 // Operator to return the free volume in a packed box double operator%(const CBox& aBox, const CBox& bBox) { return aBox.Volume() - ((aBox/bBox)*bBox.Volume()); } 使用现有的类函数,这里的计算变得非常容易。结果是大箱子 aBox 的体积减去可以容纳的全 部 bBox 箱子的体积。aBox 中包装的 bBox 对象的数量由表达式 aBox/bBox 给出,该表达式使用了 前面重载的/运算符。得到的数量再乘以 bBox 对象的体积,就是要从大箱子 aBox 的体积中减去的 体积。 至此类的接口就完成了。对于生产问题的解决方案来说,无疑还需要更多其他函数。但作为有 趣的、示范如何为解决具体问题而对类进行设计的实用模型,这个类已经足够了。我们现在可以继 续前进,试着解决一个实际的问题。 试一试:使用 CBox 类的多文件项目 在实际编写使用 CBox 类及其重载运算符的代码之前,首先需要将类定义汇编成一个连贯的整 体。我们将采用与以前完全不同的方法—— 即编写多文件的项目。还要使用 Visual C++提供的创建 和维护用户类代码的功能,这意味着只需要做较少的工作,但也意味着代码中某些地方将略微不同。 首先创建一个名为 Ex8_12 的 WIN32 控制台应用程序项目,然后选中 Empty project 应用程序选 项。如果这时选择 Class View 选项卡,那么将看到如图 8-8 所示的窗口。别忘了把项目的 Character Set 属性设置为 Not Set。 该窗口将显示项目中所有类的视图,不过此刻当然一个类也没有。虽然还没有定义的类或与之 相关的其他内容,但 Visual C++已经为包括这些类预先作了安排。可以使用 Visual C++来创建 CBox 类的框架和与之相关的文件。在 Class View 中右击 Ex8_12,并从弹出菜单中选择 Add | Class 选项。 Visual C++ 2012 入门经典(第 6 版) 334 然后,可以在显示的 Add Class 对话框左窗格中,从类种类列表中选择 C++,并在右窗格中选择 C++ Class 模板,之后按下 Enter 键。(忽略这个对话框中的 Name 和 Location 输入字段,它们是禁用的。) 接着在如图 8-9 所示的对话框中,可以输入希望创建的类的名称 CBox。 图 8-8 图 8-9 该对话框中指出的文件名 Box.cpp 用来包含由类的函数成员定义组成的类实现代码—— 即类的 可执行代码。如果愿意,那么可以修改该文件的名称,但现在 Box.cpp 看起来是个不错的文件名。 类定义将存储在名为 Box.h 的文件中。这是组织程序的标准方式。由类定义组成的代码存入扩展名 为.h 的文件,而定义函数的代码则存入扩展名为.cpp 的文件。通常,各个类定义存入各自的.h 文件, 而各个类实现代码存入各自的.cpp 文件。 当单击该对话框中的 Finish 按钮时,将发生两件事情: (1) 创建包含 CBox 类定义框架的 Box.h 文件,其中包括无参数的构造函数和析构函数。 (2) 创建 Box.cpp 文件,其中包括 CBox 类定义中构造函数和析构函数的框架实现代码—— 两个 函数体当然都是空的。 编辑器窗格目前显示 Box.h 文件中类定义的代码。编辑器窗格中的第二个选项卡显示 Box.cpp 文件的内容,即构造函数和析构函数的框架实现代码。 下面以 Visual C++自动提供的代码为基础来开发 CBox 类。 4. 定义 CBox 类 如果在 Class View 选项卡上单击 Ex8_12 左边的符号Ö,则会展开该项目树,我们看到现在项 目中已经定义了 CBox 类。项目中的所有类都将显示在这个树中。通过双击树中的类名,或者单击 显示代码窗格上方的标签,就可以查看为类定义提供的源代码。 已经生成的 CBox 类定义首先是一条预处理器指令: #pragma once 该指令的作用是防止编译器在编译过程中多次将该文件打开并嵌入到源代码中。通常,将在项 目的多个文件中嵌入包含给定类定义的头文件,因为每个引用特定类名的文件都需要访问类的定义。 这将导致某个头文件的内容有可能在源代码中多次出现。编译过程中出现某个类的多次定义是不允 第 8 章 深入理解类 335 许的,这种情况将标志为错误。在每个头文件的开头部分都放上#pragma once 指令,可以确保不出 现这种错误。 #pragma once 是 Microsoft 特有的指令,在其他开发环境中可能不支持。如果预计所开发的代码 可能需要在其他环境中编译,那么可以在头文件中使用下面的指令形式达到相同的效果: // Box.h header file #ifndef BOX_H #defi ne BOX_H // Code that must not be included more than once // such as the CBox class definition #endif 重要的几行都以粗体显示,它们是任何 C++编译器都支持的指令。只要没有定义符号 BOX_H, 编译过程中就将嵌入#ifndef 指令后面一直到#endif 指令之前的所有代码行。#ifndef 后面那一行定义 了符号 BOX_H,从而确保了该头文件中的代码不被第二次嵌入。因此,这种方法与在头文件开始 处放置#pragma once 指令具有相同的效果。显然,#pragma once 指令更简单、更整洁,因此如果只 想在 Visual C++开发环境中使用自己的代码,则最好使用该指令。有时, #ifndef/#endif 组合可以写 成下面这样: #if !defi ned BOX_H #defi ne BOX_H // Code that must not be included more than once // such as the CBox class definition #endif Class Wizard 生成的 Box.cpp 文件包含下面的代码: #include "Box.h" CBox::CBox(void) { } CBox::~CBox(void) { } 第一行是一条#include 预处理器指令,其作用是将 Box.h 文件的内容(即类定义)嵌入 Box.cpp 文 件中。这是必要的,因为 Box.cpp 文件中的代码引用了类名 CBox,而只有类定义可用时才能确定名 称 CBox 的意义。 添加数据成员 现在可以添加 double 类型的私有数据成员 m_Length、m_Width 和 m_Height。在 Class View 中 右击 CBox,并从弹出菜单中选择 Add | Add Variable 选项。然后在 Add Member Variable Wizard 对话 框中,我们可以为希望添加到该类的第一个数据成员指定名称、类型和访问特性。 在该对话框中指定新数据成员的方式非常简单。只有定义关联到某控件上的 MFC 变量时,才 给变量应用上下限。如果愿意,可以在下半部分的输入字段中添加注释。当单击 Finish 按钮时,将 在类定义中添加该变量以及提供的注释。我们应该为其他两个类数据成员 m_Width 和 m_Height 重 复上述过程。之后,Box.h 文件中的类定义将修改成如下形式: Visual C++ 2012 入门经典(第 6 版) 336 #pragma once class CBox { public: CBox(void); ~CBox(void); private: double m_Length; double m_Width; double m_Height; }; 如果愿意,那么完全可以在代码中直接手动输入这些成员的声明。我们有权选择是否使用 IDE 提供的自动功能,也可以手动删除掉自动生成的内容,但不要忘记有时候需要同时修改.h 和.cpp 文 件。手动修改之后最好保存所有文件。 如果查看 Box.cpp 文件,那么可以看到 Wizard 已经在构造函数定义中为刚才添加的那些数据成员 添加了初始化列表,列表中将各个变量初始化为 0。接下来将修改构造函数,以便做我们想做的事情。 定义构造函数 需要修改类定义中无参数构造函数的声明,从而使其包含带默认值的参数,修改后的原型如下: explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0); 现在,可以实现该函数。打开 Box.cpp 文件—— 如果尚未打开的话,将构造函数的定义修改成 如下形式: CBox::CBox(double lv, double wv, double hv) : m_Height(hv) { if(lv < 0.0 || wv < 0.0 || hv < 0.0) throw “Negative dimension specified for CBox object.”; m_Length = std::max(lv, wv); m_Width = std::min(lv, wv); // Length is now greater than or equal to width if(m_Height > m_Length) { m_Height = m_Length; m_Length = hv; // m_Height is still greater than m_Width so swap them double temp = m_Width; m_Width = m_Height; m_Height = temp; } else if( m_Height > m_Width) { m_Height = m_Width; m_Width = hv; } } 记住,成员函数形参的初始化列表只应该出现在类定义的成员声明中,而非函数的定义中。如 第 8 章 深入理解类 337 果将它们放在函数定义中,则不能编译代码。前面曾经介绍过这里的代码,因此不再讨论。需要为 algorithm 头文件添加一个#include 指令,以访问 max()和 min()函数。此刻最好单击 Save 工具栏按钮 保存文件。应该养成在切换到其他窗口之前,保存所编辑文件的习惯。如果需要再次编辑构造函数, 那么通过在 Class View 选项卡底下的窗格中双击相应的项目,或者从代码显示窗格右上角的下拉菜 单中选中该函数,即可轻松地使之再次出现在屏幕上。 在Class View 窗格中单击类名可以把类的成员显示在下部的窗格中。还可以通过在 Class View 窗格 中右击相应的名称,并从出现的上下文菜单中选择适当的菜单项,直接进入.cpp 文件中某个成员函 数的定义或.h 文件中该函数的声明。 添加函数成员 现在需要给 CBox 类添加前面看到的所有函数。此前,在类定义内部定义了一些函数成员,因 此这些函数将自动成为内联函数。如前所述,首先为 utility 头文件添加一个#include 指令以访问 Box.h,再为 std::rel_ops 名称空间添加一个 using 指令为一些比较运算符提供模板。为了把 operator<() 添加为内联函数,在 Class View 窗格中右击 CBox,并从上下文菜单中选择 Add | Add Function 菜单 项,然后就可以在显示的对话框中输入定义函数的数据,该对话框如图 8-10 所示。 图 8-10 Return Type 和 Parameter Type 下拉列表包含一组有限范围的类型。如果需要的类型没有出现在 列表中,只需手工输入它们。图 8-10 显示了输入定义函数的数据之后、添加形参之前的对话框。必 须添加 Add 按钮,才能添加形参。接着形参就显示在对话框右边的形参列表中。如果没有选中 Inline, 该函数原型就出现在类定义中,并把一个函数框架添加到 Box.cpp 文件中。还必须在 CBox 类定义 中编辑声明,使该函数声明为 const,再给函数体添加实现代码。现在它应如下所示: // Less-than operator for CBox objects bool CBox::operator<(const CBox& aBox) const { return Volume() < aBox.Volume(); } 可以用相同的方式把前面实现的 operator==()和 Vo lu me( )成员添加到类定义中: // Operator function for == comparing CBox objects Visual C++ 2012 入门经典(第 6 版) 338 bool CBox::operator==(const CBox& aBox) const { return Volume() == aBox.Volume(); } // Calculate the box volume double CBox::Volume(void) const { return m_Length*m_Width*m_Height; } Utility 头文件中的模板将处理!=、>、<=和>=运算符函数。稍后将介绍这些比较 CBox 对象和数 值的运算符函数。 现在可以把前面的 GetHeight()、GetWidth()和 GetLength()函数添加为内联的类成员。这些内容 将留给读者完成,可以使用 Add | Add Function 菜单选项或直接输入它们。 用于加法、乘法和除法操作的运算符函数可以使用 Add Member Function Wizard 输入为类成 员,如此实践一番将对我们有益。像以前一样,在 Class View 选项卡上右击 CBox,并从上下文菜单 中选择 Add | Add Function…菜单项。如图 8-11 所示,之后就可以在显示的对话框中输入 operator+() 函数的详细数据。 图 8-11 图 8-11 显示了单击 Add 按钮给列表添加形参后的对话框。当单击 Finish 按钮时,将在 Box.h 文件的类定义中添加该函数的声明,在 Box.cpp 文件中添加其框架定义。operator+()函数需要声明为 const,因此必须在类定义内该函数的声明中以及 Box.cpp 文件内该函数的定义中添加 const 关键字, 还必须添加如下所示的函数代码体: CBox CBox::operator +(const CBox& aBox) const { // New object has larger length and width of the two, // and sum of the two heights return CBox(m_Length > aBox.m_Length ? m_Length : aBox.m_Length, m_Width > aBox.m_Width ? m_Width : aBox.m_Width, m_Height + aBox.m_Height); } 第 8 章 深入理解类 339 当为前面介绍的 operator*()函数和 operator/()函数重复上述过程,之后 Box.h 文件中的类定义应 该如下所示: #pragma once #include using namespace std::rel_ops; class CBox { public: explicit CBox(double lv = 1.0, double wv = 1.0, double hv = 1.0); ~CBox(void); private: double m_Length; double m_Width; double m_Height; public: bool operator<(const CBox& aBox) const; bool operator==(const CBox& aBox) const; double Volume(void) const; double GetLength() const { return m_Length; } double GetWidth() const { return m_Width; } double GetHeight() const { return m_Height; } CBox operator+(const CBox& aBox) const; // Addition operator for CBox objects CBox operator*(int n) const; // Multiply operator for CBox objects int operator/(const CBox& aBox) const; // Division operator for CBox objects }; 可以用自己喜欢的方式编辑或重新整理这里的代码,当然,代写必须编写正确。 如果单击 Class View 选项卡后再单击 CBox 类名,那么该类的所有成员都将显示在底部窗格中。 至此 CBox 类的成员就介绍完了,但是为了将 CBox 对象的体积与数值进行比较,还需要定义 几个实现运算符的全局函数。它们是短小而高效的内联函数。 添加全局函数 您可能以为,应像其他函数定义那样把内联函数的定义放在.cpp 文件中,但其实并非如此。在 编译好的代码中,内联函数并不是“真正”的函数,因为编译器会在调用内联函数的地方插入内联 函数体的代码。在编译包含调用内联函数的文件时,内联函数的代码必须是可用的,否则就会出现 连接错误,程序就不会运行。包含在.cpp 文件中的.h 文件必须包含编译器编译.cpp 文件中代码所需 的所有内容。如果在.cpp 文件中调用了内联函数,内联函数的定义就必须出现在.h 文件中,而该.h 文件必须包含在.cpp 文件中。这也适用于在类定义外部定义的内联成员函数。 支持 CBox 对象操作的全局函数都是内联函数。可以在 Box.h 中把支持 CBox 对象操作的全局 函数放在 CBox 类定义的后面,但为了获得经验,把另一个.h 文件添加到项目中以包含它们。单击 Solution Explorer 选项卡显示解决方案浏览器(当前显示的应该是 Class View 选项卡),并右击 Header Files 文件夹。然后从上下文菜单中选择 Add | New Item 菜单项,以显示对话框。将种类选择成 Code, 并在对话框右窗格中将模板选择成 Header File (.h),然后输入文件名 BoxOperators。 现在,可以在编辑器窗格中输入下面的代码: Visual C++ 2012 入门经典(第 6 版) 340 // BoxOperators.h // CBox object operations that don't need to access private members #pragma once #include "Box.h" // Function for testing if a constant is > a CBox object inline bool operator>(const double& value, const CBox& aBox) { return value > aBox.Volume(); } // Function for testing if a constant is < CBox object inline bool operator<(const double& value, const CBox& aBox) { return value < aBox.Volume(); } // Function for testing if CBox object is > a constant inline bool operator>(const CBox& aBox, const double& value) { return aBox.Volume() > aBox.Volume; } // Function for testing if CBox object is < a constant inline bool operator<( const CBox& aBox, const double& value) { return aBox.Volume() < aBox.Volume; } // Function for testing if a constant is >= a CBox object inline bool operator>=(const double& value, const CBox& aBox) { return value >= aBox.Volume(); } // Function for testing if a constant is <= CBox object inline bool operator<=(const double& value, const CBox& aBox) { return value <= aBox.Volume(); } // Function for testing if CBox object is >= a constant inline bool operator>=( const CBox& aBox, const double& value) { return aBox.Volume() >= aBox.Volume; } // Function for testing if CBox object is <= a constant inline bool operator<=( const CBox& aBox, const double& value) { return aBox.Volume() <= aBox.Volume; } // Function for testing if a constant is == CBox object inline bool operator==(const double& value, const CBox& aBox) { return value == aBox.Volume(); } // Function for testing if CBox object is == a constant inline bool operator==(const CBox& aBox, const double& value) { return aBox.Volume() == value; } // CBox multiply operator n*aBox inline CBox operator*(int n, const CBox& aBox) { return aBox * n; } // Operator to return the free volume in a packed CBox inline double operator%( const CBox& aBox, const CBox& bBox) { return aBox.Volume() - (aBox / bBox) * bBox.Volume(); } #pragma once 指令将确保在编译过程中只能嵌入一次本文件的内容。上面的代码中有一条嵌入 Box.h 文件的#include 指令,因为这些函数要引用 CBox 类。保存该文件。完成这些代码的输入之后, 可以选择 Class View 选项卡。Class View 选项卡现在包括 Global Functions and Variables 文件夹,其 中包含刚才添加的所有函数。前面已经介绍过所有这些函数的定义,因此这里不再讨论。 第 8 章 深入理解类 341 当在一个非内联函数的项目中定义全局函数时,可以把它们的定义放在一个.cpp 文件中。还需 要把这些函数的原型放在一个开头包含#pragma once 指令的.h 文件中。接着把这个.h 文件包含到调 用任意全局函数的.cpp 文件中,这样编译器就知道它们是什么。 现在可以着手将这些函数和 CBox 类应用到箱子的问题上来。 使用 CBox 类 假设我们在包装糖果。这些糖果都在破碎机上各占用 1.5 英寸长、1 英寸宽、1 英寸高的空间。 可以使用 4.5 英寸长、7 英寸宽、2 英寸高的标准糖果盒,现在想知道盒中能够容纳多少块糖果,以 便制定价格。还有一种标准的纸板箱,长 2 英尺 6 英寸、宽 18 英寸、深 18 英寸。我们想知道该纸 板箱可以容纳多少个糖果盒,装满之后有多大的未用空间。 万一标准糖果盒不是合适的解决方案,我们还想知道定制多大尺寸的糖果盒才合适。如果糖果 盒的长度在 3~7 英寸之间,宽度在 3~5 英寸之间,高度在 1~2.5 英寸之间(这些尺寸可以以半英寸为 步距变化),就能卖个好价钱。糖果盒中至少需要 30 块糖果,因为这是绝大多数顾客一次消费的最 低数量。另外,糖果盒不应该有剩余空间,那样将使消费者感到上当受骗。理想情况下我们还希望 纸板箱塞满,这样糖果盒将不会晃荡。我们也不希望包装得太紧,那样将增加包装的难度,因此如 果纸板箱的剩余空间小于一个糖果盒的体积,就可以说没有浪费空间。 使用 CBox 类,该问题很容易解决,解决方案由下面的 main()函数给出。像以前一样,右击 Solution Explorer 窗格中的 Source Files 文件夹,从弹出的上下文菜单中选择适当的菜单项,给本项目添加一 个新的 C++源文件 Ex8_12.cpp,然后输入下面显示的代码: // Ex8_12.cpp // A sample packaging problem #include #include "Box.h" #include "BoxOperators.h" using std::cout; using std::endl; int main() { CBox candy(1.5, 1.0, 1.0); // Candy definition CBox candyBox(7.0, 4.5, 2.0); // Candy box definition CBox carton(30.0, 18.0, 18.0); // Carton definition // Calculate candies per candy box int numCandies = candyBox/candy; // Calculate candy boxes per carton int numCboxes = carton/candyBox; // Calculate wasted carton space double space = carton%candyBox; cout << endl << "There are " << numCandies << " candies per candy box" << endl << "For the standard boxes there are " << numCboxes << " candy boxes per carton " << endl << "with " << wasted << " cubic inches wasted."; cout << endl << endl << "CUSTOM CANDY BOX ANALYSIS (No Waste)"; Visual C++ 2012 入门经典(第 6 版) 342 const int minCandiesPerBox = 30; // Try the whole range of custom candy boxes for(double length = 3.0 ; length <= 7.5 ; length += 0.5) { for(double width = 3.0 ; width <= 5.0 ; width += 0.5) { for(double height = 1.0 ; height <= 2.5 ; height += 0.5) { // Create new box each cycle CBox tryBox(length, width, height); if((carton%tryBox < tryBox.Volume()) && // Carton waste < a candy box (tryBox % candy == 0.0) && // & no waste in candy box (tryBox/candy >= minCandiesPerBox)) // & candy box holds minimum cout << endl << endl << "Trial Box L = " << tryBox.GetLength() << " W = " << tryBox.GetWidth() << " H = " << tryBox.GetHeight() << endl << "Trial Box contains " << tryBox / candy << " candies" << " and a carton contains " << carton / tryBox << " candy boxes."; } } } cout << endl; return 0; } 首先来看该程序的结构。该程序被分为多个文件,这在 C++编程中很常见。如果切换到 Solution Explorer 选项卡(见图 8-13),就能够看到这些文件。Ex8_12.cpp 文件包含 main()函数和嵌入 Box- Operators.h 头文件的#include 指令,而 BoxOperators.h 头文件包含 BoxOperators.cpp 文件中所有函数 的原型(它们不是类成员)。Ex8_12.cpp 文件还包含一条嵌入 Box.h 文件中 CBox 类定义的#include 指 令。C++控制台程序通常被分为多个文件,它们各自属于下列 3 个基本类别之一: (1) 包含库文件#include 指令、全局常量和变量、类定义以及函数原型的.h 文件。换句话说,.h 文件包含除可执行代码以外的一切。它们还包含内联函数的定义。当程序中有多个类定义时,通常 将这些类分别放入单独的.h 文件中。 (2) 包含程序的可执行代码的.cpp 文件,其中还包含可执行代码所需全部定义的#include 指令。 (3) 包含 main()函数的另一个.cpp 文件。 实际上不需要解释 main()函数中的代码 —— 它们几乎就是对问题定义的直接文字表示,因为是 类接口中的运算符对 CBox 对象执行面向问题的动作。 使用标准箱子这个问题的答案在 main()函数开始部分的声明语句中,这些语句将所需的答案计 算出来作为初始化值。然后,输出这些值,输出时添加了一些解释性的注释。 该问题的第二部分是使用 3 个嵌套的 for 循环解决的。这些循环在 m_Length、m_Width 和 m_Height 的允许范围内迭代,从而评估所有可能的组合。可以将这些组合全部输出到屏幕上,但因 为涉及的组合多达 200 个,而我们可能只对其中一小部分感兴趣,所以程序中使用一条 if 语句来识 第 8 章 深入理解类 343 别那些我们感兴趣的选项。仅当纸板箱中没有浪费的空间、当前试验的糖果盒中没有浪费的空间, 并且该糖果盒至少包含 30 块糖果时,if 表达式才是 true。 示例说明 该程序的输出如下: There are 42 candies per candy box For the standard boxes there are 144 candy boxes per carton with 648 cubic inches wasted. CUSTOM CANDY BOX ANALYSIS (No Waste) Trial Box L = 5 W = 4.5 H = 2 Trial Box contains 30 candies and a carton contains 216 candy boxes. Trial Box L = 5 W = 4.5 H = 2 Trial Box contains 30 candies and a carton contains 216 candy boxes. Trial Box L = 6 W = 3 H = 2.5 Trial Box contains 30 candies and a carton contains 216 candy boxes. Trial Box L = 6 W = 4.5 H = 2 Trial Box contains 36 candies and a carton contains 180 candy boxes. Trial Box L = 6 W = 4.5 H = 2.5 Trial Box contains 45 candies and a carton contains 144 candy boxes. Trial Box L = 6 W = 5 H = 1.5 Trial Box contains 30 candies and a carton contains 216 candy boxes. Trial Box L = 6 W = 5 H = 2 Trial Box contains 40 candies and a carton contains 162 candy boxes. Trial Box L = 6 W = 5 H = 2.5 Trial Box contains 50 candies and a carton contains 129 candy boxes. Trial Box L = 7.5 W = 3 H = 2 Trial Box contains 30 candies and a carton contains 216 candy boxes. 因为嵌套循环中既评估长 5 英寸、宽 4.5 英寸的箱子,也评估长 4.5 英寸、宽 5 英寸的箱子,所 以得到一个重复的解决方案。因为 CBox 类的构造函数能够确保长度不小于宽度,所以这两个答案 是相同的。可以包括一些避免出现重复方案的其他逻辑,但这样做几乎没有效果。如果愿意,那么 您可以将其当作一道练习题试一下。 8.10 组织程序代码 在示例 Ex8_12 中,第一次将程序代码分布在多个文件中。这种作法不仅在 C++应用程序中常 见,而且在 Windows 编程中也必不可少。即使最简单的程序所包含的大量代码,也有必要将它分为 若干可使用的代码块。 如第 8.9 节所述,C++程序中有两种基本的源代码文件:.h 和.cpp 文件,图 8-12 说明了这一点。 Visual C++ 2012 入门经典(第 6 版) 344 类函数定义 类函数定义 全局函数定义 main()的定义 带有扩展名.cpp 的源文件 类定义 类定义 全局常量 全局常量 带有扩展名.h 的头文件 程序源代码 图 8-12 我们可能时常需要在新项目中使用现有文件中的代码。这种情况下,只需给新项目添加.cpp 文 件即可,为此可以使用 Project | Add Existing Item 菜单项。另外,右击 Solution Explorer 选项卡中的 Source Files 或 Header Files 文件夹,然后从上下文菜单中选择 Add | Existing Item 菜单项,也可以在 新项目中添加文件。我们不需要给新项目添加.h 文件,不过如果希望.h 文件立即出现在 Solution Explorer 窗格中,那么添加它们也无妨。作为指定的#include 指令的结果,将.h 文件中的代码添加到 需要它们的.cpp 文件的开始部分。我们需要#include 指令来嵌入包含标准库函数和其他标准定义的 头文件,以及我们自己的头文件。Visual C++能够自动记住所有这些文件,并允许在 Solution Explorer 选项卡中查看它们。如上一个示例所示,还可以在 Class View 选项卡中查看类定义、全局常量和 变量。 在 Windows 程序中,还有其他几种用于说明像菜单和工具栏按钮这样一些对象的定义。这些定 义存储在扩展名为.rc 和.ico 的文件中。需要时 Visual C++将自动创建并跟踪它们。 命名程序文件 如前所述,无论类的复杂性如何,通常都应该将类定义存储在文件名基于类名的.h 文件中,将 在类定义外部定义的函数成员的实现存储在同名的.cpp 文件中。以此为根据,CBox 类的定义出现 第 8 章 深入理解类 345 在名为 Box.h 的文件中。同理,类的实现存储在 Box.cpp 文件中。本章前面的示例没有遵守这项约 定,因为那些示例非常短小,而且名称由章编号和章内示例序号构成的示例更容易引用。无论程序 的大小如何,既然以这种方式组织代码是必要的,那么最好从现在开始养成创建.h 和.cpp 文件来容 纳程序代码的习惯。 将 C++程序分为.h 和.cpp 文件是一种非常方便的方法,使我们很容易找到任何类的定义或实现。 如果使用的开发环境没有包括 Visual C++提供的全部工具,则上述优点更加明显。只要知道类名, 就能直接找到想要的文件。但这不是一条严格的规则。有时候,需要将一组紧密相关的类的定义集 中到一个文件中,并以类似的方式将它们的实现也汇编在一起。但无论选择怎样的文件组织方式, Class View 选项卡都将显示所有的类以及各个类的全部成员。双击 Class View 树中的任意一项将直 接进入相关的源代码。 8.11 字符串的库类 第 4 章已经提及,string 头文件中定义了表示字符串的 string 和 wstring 类。将这两个类都定义 为类模板 basic_string的实例:string 类定义为 basic_string ,wstring 类定义为 basic_string 。因此 string 类表示 char 类型的字符串,而 wstring 类表示 wchar_t 类型的字符串。 这些字符串类型比以空字符结尾的字符串容易使用得多,它们还配备了一整套功能强大的函数。 因为 string 和 wstring 都是同一个模板 basic_string的实例,它们提供的功能相同,因此本节仅讨 论 string 类型的功能与用法。除了字符串中含有 Unicode 字符代码,并且必须在代码中字符串字面 值前加上前缀 L 外,wstring 类型的运行与 string 类型完全一样。 8.11.1 创建字符串对象 字符串对象的创建非常容易,但具体如何创建有不少选择。首先,可以像下面这样创建并初始 化一个字符串对象: string sentence = "This sentence is false."; sentence 对象将用赋值运算符右边的字符串字面值来初始化。还可以使用下面的函数记号: string sentence(“This sentence is false.”); 由于字符串对象末尾没有空字符,因此字符串长度是字符串中的字符个数,在本实例中是 23。 可以在任何时候通过调用字符串对象的 length()成员函数,来查看字符串对象所封装的字符串的长 度。例如: cout << "The string is of length " << sentence.length() << endl; 执行该语句将产生如下输出: The string is of length 23 顺便提一下,可以用与任何其他变量相同的方式将字符串对象输出到 cout: cout << sentence << endl; Visual C++ 2012 入门经典(第 6 版) 346 该代码在它本身的那一行上显示 sentence 字符串。也可以像下面这样将字符串读到字符串对象中: cin >> sentence; 然而,以这种方式从 stdin 中读取字符串时会忽略开头的空格,直到发现非空格的字符。当在一 个或多个非空格字符后面输入一个空格时,这种方式也会终止输入。我们常常希望将包括空格的文 本读入一个字符串对象(可能跨多行)中。在本例中,使用在 string 头文件中定义的 getline()函数模板 要方便得多。例如: getline(cin, sentence, '*'); 这个函数模板专门用来将数据从流中读入到 string 或 wstring 对象中。第一个实参是作为输入源 的流—— 它不一定是 cin;第二个实参是接收输入的对象;第三个实参是终止读取的字符。这里将终 止字符指定为'*',因此该语句会将 cin 中的文本(包括空格)读入 sentence 中,直到从输入流中读到以 星号指示的输入末尾。 如果在创建 string 对象时没有指定初始字符串字面值,该对象就会包含一个空字符串: string astring; // Create an empty string 调用字符串 astring 的 length()将返回结果 0。 另一种可能性是用一个重复指定次数的字符来初始化字符串对象: string bees(7, 'b'); // String is "bbbbbbb" 该构造函数的第一个实参是第二个实参指定的字符的重复次数。 最后一种方式是用另一个字符串对象的全部或一部分来初始化字符串对象。下面是用另一个字 符串对象作为初始化器的示例: string letters(bees); 这里将用 bees 中包含的字符串来初始化 letters 对象。 为了选择一个字符串对象的一部分作为初始值设定项,我们调用带 3 个实参的字符串构造函数, 第一个实参是作为初始化字符串来源的字符串对象,第二个实参是要选择的第一个字符的索引位置, 第三个实参是要选择的字符个数。例如: string sentence("This sentence is false."); string part(sentence, 5, 11); 这里用 sentence 中从第 6 个字符(第一个字符位于索引位置 0)开始的 11 个字符来初始化 part 对 象。因此 part 包含字符串“sentence is”。 当然,可以创建 string 对象的数组,并用常规表示法来初始化它们。例如: string animals[] = { "dog", "cat", "horse", "donkey", "lion"}; 上面的代码创建包含 5 个字符串对象的一个数组,用大括号括起来的字符串字面值初始化这些 元素。 8.11.2 连接字符串 字符串最常见的运算可能是连接两个字符串形成一个新的字符串了。可以用+运算符连接两个 第 8 章 深入理解类 347 字符串对象或者一个字符串对象和一个字符串字面值。下面是一些示例: string sentence1("This sentence is false."); string sentence2("Therefore the sentence above must be true!"); string combined; // Create an empty string sentence1 = sentence1 + "\n"; // Append string containing newline combined = sentence1 + sentence2; // Join two strings cout << combined << endl; // Output the result 执行这些语句将得到下面的输出: This sentence is false. Therefore the sentence above must be true! 前 3 个语句创建字符串对象。下一个语句将字符串字面值"\n"附加到 sentence1 后面,并将结果 存储在 sentence1 中。再下一个语句连接 sentence1 和 sentence2,并将结果存储在 combined 中。最后 一个语句输出字符串 combined。 字符串可以用+运算符连接,是因为 string 类实现了 operator+()。这意味着操作数之一必须为 string 对象,因此不能用+运算符连接两个字符串字面值。记住,每次用+运算符连接两个字符串时, 都是在创建一个新的 string 对象,这会带来一定的开销。下一节将介绍如何修改和扩展现有的 string 对象,在有些情况下这可能是一种更有效的方法,因为它不涉及创建新对象。 也可以用+运算符将一个字符连接到一个字符串对象上,因此前面代码片段中的第 4 个语句可 以写成: sentence1 = sentence1 + '\n'; // Append newline character to string string 类也可以实现 operator+=(),因此右操作数可以是一个字符串字面值、一个字符串对象或 者一个字符。前面的语句可以写成: sentence1 += '\n'; 或者写成: sentence1 += "\n"; 使用+=运算符与使用+运算符有一个区别。如前所述,+运算符创建一个包含合并后的字符串 的新字符串对象。+=运算符将作为右操作数的字符串或字符附加到作为左操作数的 string 对象后面, 因此直接修改 string 对象,而不创建新的对象。 下面用一个示例来练习上面描述的概念。 试一试:创建与连接字符串 这一简单示例通过键盘输入姓名和年龄,然后列出输入的内容。代码如下: // Ex8_13.cpp // Creating and joining string objects #include #include using std::cin; using std::cout; Visual C++ 2012 入门经典(第 6 版) 348 using std::endl; using std::string; using std::getline; // List names and ages void listnames(string names[], string ages[], size_t count) { cout << endl << "The names you entered are: " << endl; for(size_t i = 0 ; i < count && !names[i].empty() ; ++i) cout << names[i] + " aged " + ages[i] + '.' << endl; } int main() { const size_t count = 100; string names[count]; string ages[count]; string firstname; string secondname; for(size_t i = 0 ; i < count ; i++) { cout << endl << "Enter a first name or press Enter to end: "; getline(cin, firstname, '\n'); if(firstname.empty()) { listnames(names, ages, i); cout << "Done!!" << endl; return 0; } cout << "Enter a second name: "; getline(cin, secondname, '\n'); names[i] = firstname + ' ' + secondname; cout << "Enter " + firstname + "'s age: "; getline(cin, ages[i], '\n'); } cout << "No space for more names." << endl; listnames(names, ages, count); return 0; } ages 通常是一个整数数组,但这里把它当成是一个字符串数组,只是为了更多地使用字符串。 这个示例产生类似下面的输出: Enter a first name or press Enter to end: Marilyn Enter a second name: Munroe Enter Marilyn's age: 26 Enter a first name or press Enter to end: Tom Enter a second name: Crews Enter Tom's age: 45 Enter a first name or press Enter to end: Arnold Enter a second name: Weisseneggar 第 8 章 深入理解类 349 Enter Arnold's age: 52 Enter a first name or press Enter to end: The names you entered are: Marilyn Munroe aged 26. Tom Crews aged 45. Arnold Weisseneggar aged 52. Done!! 示例说明 listnames 函数列出存储在数组中的姓名和年龄(它们作为前两个实参传递)。第三个实参是数组 中元素的个数。数据的清单出现在一个循环中: for(size_t i = 0 ; i < count && !names[i].empty() ; ++i) cout << names[i] + " aged " + ages[i] + '.' << endl; 循环条件是双重保险(belt and brace)控制机制,它不仅检查索引 i 是否小于作为第三个实参传递 的 count 的值,而且还调用当前元素的 empty()函数来确认它不是空字符串。循环体中的那个语句用 +运算符连接 names[i]中的当前字符串与字面值"aged"、ages[i]字符串及字符'.',并将产生的字符串 写到 cout 中。这个连接字符串的表达式等价于: ((names[i].operator+(" aged ")).operator+(ages[i])).operator+('.') 每次调用 operator+()函数都会返回一个新的 string 对象。所以此表达式演示了将一个 string 对 象与一个字符串字面值合并,将一个 string 对象与另一个 string 对象合并,以及将一个 string 对象 与一个字符字面值合并。 虽然上面的代码演示说明了 string::operator+()函数的用法,但出于性能考虑使用下面的语句: cout << names[i] << " aged " << ages[i] << '.' << endl; 这避免了调用运算符函数和由此而导致的创建所有字符串对象。 在 main()中,首先用下面的语句创建两个 string 对象的数组,长度为 count: const size_t count = 100; string names[count]; string ages[count]; names 和 ages 数组将存储从键盘上输入的姓名和相应的年龄值。 在 main()的 for 循环内,分别用 getline()函数模板来读取姓和名: cout << endl << "Enter a first name or press Enter to end: "; getline(cin, firstname, '\n'); if(firstname.empty()) { listnames(names, ages, i); cout << "Done!!" << endl; return 0; } cout << "Enter a second name: "; getline(cin, secondname, '\n'); Visual C++ 2012 入门经典(第 6 版) 350 getline()函数允许读空字符串,而使用 cin 的>>运算符不能读空字符串。getline()的第一个实参 是作为输入源的流,第二个实参是输入的目的地,第三个实参是标志输入操作结束的字符。如果省 略第三个实参,输入'\n'将终止输入过程,因此这里可以省略第三个实参。使用读空字符串的能力来 测试 firstname 中的空字符串(通过调用它的 empty()函数)。由于空字符串是输入结束的信号,因此调 用 listnames()来输出数据,并结束程序的执行。 当 firstname 不为空时,继续用 getline()模板函数将姓读入 secondname 中。使用+运算符连接 firstname 和 secondname,并将结果存储在 names[i]中,它是 names 数组中当前未使用的元素。 在循环中最后读入年龄字符串,并将结果存储在 ages[i]中。for 循环将条目数限制为 count,它 对应于数组中的元素个数。如果没能成功地结束循环,数组在显示一条消息(表明已经输出了所输入 的数据)后就满了。 8.11.3 访问与修改字符串 可以使用下标操作符[]来访问 string 对象中的任何字符,从而读取或重写它。举例如下: string sentence("Too many cooks spoil the broth."); for(size_t i = 0; i < sentence.length(); i++) { if(' ' == sentence[i]) sentence[i] = '*'; } 这段代码依次检查 sentence 字符串中的每个字符,看看它是否为空格,如果是,则用星号替换 该字符。 用 at()成员函数与用[]操作符得到的结果相同: string sentence("Too many cooks spoil the broth."); for(size_t i = 0; i < sentence.length(); i++) { if(' ' == sentence.at(i)) sentence.at(i) = '*'; } 这段代码与上一段代码的功能完全相同,那么使用[]与使用 at()的区别在哪里呢?利用下标值的 速度比使用 at()函数快,但缺点是没有检查索引的有效性。如果索引超出了范围,那么使用下标操作 符的结果将不确定。另一方面,at()函数稍微慢一点儿,但它会检查索引的有效性,如果索引无效, 该函数就会抛出一个 out_of_range 异常。当索引值有可能超出范围时,可以使用 at()函数,在这种情 况下把代码放在 try 块中,并恰当地处理异常。如果我们能确信不会出现索引超出范围的情况,就 使用[]操作符。 可以对字符串对象使用基于范围的 for 循环,迭代字符串中的所有字符: string sentence("Too many cooks spoil the broth."); for(auto& ch : sentence) { if(' ' == ch) ch = '*'; } 第 8 章 深入理解类 351 这段代码也用星号替换空格,就像前面的循环一样,但这段代码要简单得多。对 ch 使用引用类 型就可以修改字符串。 可以提取现有 string 对象的一部分作为一个新的 string 对象。例如: string sentence("Too many cooks spoil the broth."); string substring = sentence.substr(4, 10); // Extracts "many cooks" substr()函数的第一个实参是要提取的子字符串的第一个字符,第二个实参是子字符串中的 字符个数。 使用字符串对象的 append()函数,可以在字符串末尾添加一个或多个字符。该函数有几个版本; 包括向调用该函数的对象附加一个或多个给定字符、一个字符串字面值或者一个 string 对象。例如: string phrase("The higher"); string word("fewer"); phrase.append(1, ' '); // Append one space phrase.append("the "); // Append a string literal phrase.append(word); // Append a string object phrase.append(2, '!'); // Append two exclamation marks 当执行这个代码序列后,phrase 将修改为“The higher the fewer!!”。使用带两个实参的 append() 版本时,第一个实参是将被附加第二个实参指定的字符的次数。当调用 append()函数时,它返回 对调用该函数的对象的引用,因此可以在一个语句中写出上面 4 个 append()调用: phrase.append(1, ' ').append("the ").append(word).append(2, '!'); 也可以用 append()将字符串字面值的一部分或 string 对象的一部分附加到一个现有字符串后面: string phrase("The more the merrier."); string query("Any"); query.append(phrase, 3, 5).append(1, '?'); 执行这些语句的结果是 query 会包含字符串“Any more?”。在最后一个语句中,对 append()函 数的第一个调用有 3 个实参: ● 第一个实参 phrase 是从中提取字符并附加到 query 后面的 string 对象。 ● 第二个实参 3 是要提取的第一个字符的索引位置。 ● 第三个实参 5 是要附加的字符总数。 因此这个调用会将子字符串“more”附加到 query 后面。对 append()函数的第二个调用在 query 后面加上一个问号。 当想向一个字符串对象后面附加一个字符时,可以使用 push_back()函数作为 append()的替换函 数。用法如下: query.push_back('*'); 此代码向 query 字符串的末尾附加一个星号字符。 有时,仅仅向字符串末尾添加字符还不够。有时可能需要在字符串中间的某个位置插入一个或 多个字符。insert()函数的各种版本可以完成这一任务: string saying("A horse"); Visual C++ 2012 入门经典(第 6 版) 352 string word("blind"); string sentence("He is as good as gold."); string phrase("a wink too far"); saying.insert(1, " "); // Insert a space character saying.insert(2, word); // Insert a string object saying.insert(2, "nodding", 3); // Insert 3 characters of a string literal saying.insert(5, sentence, 2, 15); // Insert part of a string at position 5 saying.insert(20, phrase, 0, 9); // Insert part of a string at position 20 saying.insert(29, " ").insert(30, "a poor do", 0, 2); 执行上面的语句后,saying 将包含字符串“A nod is as good as a wink to a blind horse”。insert()的 各版本的形参如表 8-1 所示。 表 8-1 函 数 原 型 说 明 string& insert(size_t index, const char* pstring) 在 index 位置插入以空字符结尾的字符串 pstring string& insert(size_t index, const string& astring) 在 index 位置插入 string 对象 astring string& insert(size_t index, const char* pstring, size_t count) 在 index 位置插入以空字符结尾的字符串 pstring 中的前 count 个字符 string& insert(size_t index, size_t count, char ch) 在 index 位置插入字符 ch 的 count 个副本 string& insert(size_t index, const string& astring, size_t start, size_t count) 在 string 对象 astring 中从 start 位置的字符开始的 count 个字符;子字符串插 入在 index 位置 insert()的这些版本都返回对调用该函数的 string 对象的一个引用。这样,就可以像上面代码片 段中的最后一个语句那样将所有调用链接在一起。 表 8-1 并不是 insert()函数的完整集合,但是可以用该表中的版本做任何事情。其他版本用第 10 章将介绍的迭代器(iterator)作为实参。 调用 swap()成员函数可以交换封装在两个 string 对象之间的字符串。例如: string phrase("The more the merrier."); string query("Any"); query.swap(phrase); 结果是 query 包含字符串“The more the merrier.”,phrase 包含字符串“Any”。当然,执行 phrase.swap(query)也能得到同样的效果。 如果需要将一个字符串对象转换为以空字符结尾的字符串,可以用 c_str()函数来完成。例如: string phrase("The higher the fewer"); const char *pstring = phrase.c_str(); c_str()函数返回一个指向以空字符结尾的字符串的指针,该字符串的内容与 string 对象相同。 调用 data()成员函数也可以获得一个字符串对象(作为 char 类型的数组)的内容。注意,该数组仅 第 8 章 深入理解类 353 包含字符串对象中的字符,不包括结尾的空字符。 调用字符串对象的 replace()成员函数可以替换字符串对象的一部分。它也有几个版本,如表 8-2 所示。 表 8-2 函 数 原 型 说 明 string& replace(size_t index, size_t count, const char* pstring) 用 pstring 中的前 count 个字符替换从 index 位置开始的 count 个字符 string& replace(size_t index, size_t count, const string& astring) 用 astring 中的前 count 个字符替换从 index 位置开始的 count 个字符 string& replace(size_t index, size_t count1, const char* pstring, size_t count2) 用 pstring 中第一个到第 count2 个字符替换从 index 位置开始的 count1 个字 符。这个版本允许替换子字符串比被替换的子字符串更长或更短 string& replace(size_t index1, size_t count1, const string& astring, size_t index2, size_t count2) 用 astring 中从 index2 位置开始的 count2 个字符替换从 index1 位置开始的 count1 个字符 string& replace(size_t index, size_t count1, size_t count2, char ch) 用 count2 个字符 ch 替换从 index 位置开始的 count1 个字符 表 8-2 的各个版本都会返回对调用该函数的 string 对象的引用。 举例如下: string proverb("A nod is as good as a wink to a blind horse"); string sentence("It's bath time!"); proverb.replace(38, 5, sentence, 5, 3); 这段代码采用表 8-2 中 replace()函数的第 4 个版本,用“bat”替换字符串 proverb 中的“horse”。 8.11.4 比较字符串 我们有一整套比较运算符,用来比较两个字符串对象,或者比较一个字符串对象与一个字符串 字面值。string 类中对下列运算符实现了运算符重载: == != < <= > >= 下面是使用这些运算符的示例: string dog1("St Bernard"); string dog2("Tibetan Mastiff"); if(dog1 < dog2) cout << "dog2 comes first!" << endl; else if(dog1 > dog2) Visual C++ 2012 入门经典(第 6 版) 354 cout << "dog1 comes first!" << endl; 当比较两个字符串时,实际上是比较对应的字符,直到发现一对不同的字符,或者到达一个或 两个字符串的末尾。当发现两个对应字符不相同时,字符代码的值决定比较结果。如果没有发现不 同的字符对,那么字符较少的字符串小于另一个字符串。如果两个字符串包含相同的字符个数,而 且对应的字符也相同,则这两个字符串相等。 试一试:比较字符串 本例说明了如何用极其低效的排序方法来使用比较运算符。代码如下: // Ex8_14.cpp // Comparing and sorting words #include #include #include using std::cin; using std::cout; using std::endl; using std::ios; using std::setiosflags; using std::setw; using std::string; string* sort(string* strings, size_t count) { bool swapped(false); while(true) { for(size_t i = 0 ; i < count-1 ; i++) { if(strings[i] > strings[i+1]) { swapped = true; strings[i].swap(strings[i+1]); } } if(!swapped) break; swapped = false; } return strings; } int main() { const size_t maxstrings(100); string strings[maxstrings]; size_t nstrings(0); size_t maxwidth(0); // Read up to 100 words into the strings array while(nstrings < maxstrings) { 第 8 章 深入理解类 355 cout << "Enter a word or press Enter to end: "; getline(cin, strings[nstrings]); if(maxwidth < strings[nstrings].length()) maxwidth = strings[nstrings].length(); if(strings[nstrings].empty()) break; ++nstrings; } // Sort the input in ascending sequence sort(strings,nstrings); cout << endl << "In ascending sequence, the words you entered are:" << endl << setiosflags(ios::left); // Left-justify the output for(size_t i = 0 ; i < nstrings ; i++) { if(i % 5 == 0) cout << endl; cout << setw(maxwidth+2) << strings[i]; } cout << endl; return 0; } 下面是该示例的一些典型输出: Enter a word or press Enter to end: loquacious Enter a word or press Enter to end: transmogrify Enter a word or press Enter to end: abstemious Enter a word or press Enter to end: facetious Enter a word or press Enter to end: xylophone Enter a word or press Enter to end: megaphone Enter a word or press Enter to end: chauvinist Enter a word or press Enter to end: In ascending sequence, the words you entered are: abstemious chauvinist facetious loquacious megaphone transmogrify xylophone 示例说明 sort()函数最有趣的部分是它接受两个实参:字符串数组的地址与数组元素的个数。该函数使用 冒泡排序法,方法是按顺序扫描元素并逐个比较它们。所有工作都在 while 循环中完成: bool swapped(false); while(true) { for(size_t i = 0 ; i < count-1 ; i++) { if(strings[i] > strings[i+1]) { swapped = true; strings[i].swap(strings[i+1]); Visual C++ 2012 入门经典(第 6 版) 356 } } if(!swapped) break; swapped = false; } 上述代码中用>运算符比较 strings 数组中的逐个元素。如果一对元素中第一个元素大于第二个 元素,就交换这两个元素。在这种情况下,通过调用一个 string 对象的 swap()函数,并将另一个 string 对象作为实参来交换元素。根据需要继续逐个比较整个数组的元素,并进行交换。这一过程一直重 复到处理完所有元素,且没有元素需要交换时为止。然后,元素就变成了升序排列。bool 变量 swapped 充当指示器,表明在给定的比较过程中有没有发生交换。仅当交换两个元素时,才会将它设置为 true。 main()函数最多能在一个循环中向字符串数组中读入 100 个单词: while(nstrings < maxstrings) { cout << "Enter a word or press Enter to end: "; getline(cin, strings[nstrings]); if(maxwidth < strings[nstrings].length()) maxwidth = strings[nstrings].length(); if(strings[nstrings].empty()) break; ++nstrings; } 这里 getline()函数从 cin 中读取字符,直至读到'\n'。输入存储在第二个实参 strings[nstrings]指定 的 string 对象中。只需按下 Enter 键就会导致一个 empty()字符串,因此当读取的最后一个 string 对 象的 empty()函数返回 true 时终止循环。maxwidth 变量用来记录输入的最长字符串的长度。在对输 入内容进行排序以后的输出过程中会用到它。 调用 sort()函数可以按升序对 strings 数组中的内容进行排序。结果是在一个循环中输出: cout << endl << "In ascending sequence, the words you entered are:" << endl << setiosflags(ios::left); // Left-justify the output for(size_t i = 0 ; i < nstrings ; i++) { if(i % 5 == 0) cout << endl; cout << setw(maxwidth+2) << strings[i]; } 这段代码在宽度为 maxwidth+2 个字符的字段中输出各个元素。因为调用了 setiosflags()操作符, 实参为 ios::left,所以字段中的各个单词保持左对齐。与 setw()操作符不同,在重置之前 setiosflags() 操作符仍然有效。 8.11.5 搜索字符串 搜索 string 对象中给定字符或子字符串的 find()函数有 4个版本,分别列出在表 8-3 中。所有 find() 函数都定义为 const。 第 8 章 深入理解类 357 表 8-3 函 数 说 明 size_t find( char ch, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始的字符 ch。可以省略第二 个实参,在这种情况下默认值为 0 size_t find( char char* pstr, size_t offset=0) 在string 对象中搜索从 offset 索引位置开始的以空字符结尾的字符串 pstr。可以省略第二个实参,在这种情况下默认值为 0 size_t find( const char* pstr, size_t offset, size_t count) 在string 对象中搜索从 offset 索引位置开始的以空字符结尾的字符串 pstr 的前 count 个字符 size_t find( const string& str, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始的字符串对象 str。可以省 略第二个实参,在这种情况下默认值为 0 find()函数的各个版本都返回发现的字符或子字符串的第一个字符的索引位置。如果没有找到要 找的条目,该函数会返回值 string::npos。后一个值是在 string 类中定义的一个常量,表示 string 对象 中的一个非法位置,它通常用来标识搜索失败。 下面的代码片段显示了 find()函数的部分用法: string phrase("So near and yet so far"); string str("So near"); cout << phrase.find(str) << endl; // Outputs 0 cout << phrase.find("so far") << endl; // Outputs 16 cout << phrase.find("so near") << endl; // Outputs string::npos = 4294967295 string::nops 的值可能根据不同的编译器实现而不同,因此为了测试它,应该总是使用 string::npos, 而不是使用显式的值。 下面是反复扫描同一个字符串以搜索特定子字符串的又一示例: string str( "Smith, where Jones had had \"had had\", \"had had\" had." " \"Had had\" had had the examiners' approval."); string substr("had"); cout << "The string to be searched is:" << endl << str << endl; size_t offset(0); size_t count(0); size_t increment(substr.length()); while(true) { offset = str.find(substr, offset); if(string::npos == offset) break; offset += increment; ++count; } cout << endl << " The string \"" << substr << "\" was found " << count << " times in the string above." << endl; Visual C++ 2012 入门经典(第 6 版) 358 这段代码搜索字符串 str,查 看 其 中 出 现“ had”的次数。此搜索在 while 循环中完成,其中 offset 记录发现的位置,该位置也用作搜索的起始位置。该搜索从索引位置 0(字符串的开头)开始,每次发 现子字符串时,就将下一次搜索的新起始位置设置为发现的位置加上子字符串的长度。这样可以确 保绕过发现的子字符串。每次发现子字符串时,就递增 count。如果 find()返回 string::npos,就表示 没有发现子字符串,搜索结束。执行该代码片段产生如下输出: The string to be searched is: Smith, where Jones had had "had had", "had had" had. "Had had" had had the examiners' approval. The string "had" was found 10 times in the string above. 当然,“Had”与“had”不匹配,因此正确结果为 10。 find_first_of()和 find_last_of()成员函数在 string 对象中搜索给定集合中的任何字符。例如,可能 在字符串中搜索空格或标点符号(它们可以用来将一个字符串分解为单个单词)。这两个函数都有几 个版本,如表 8-4 所示。表中的所有函数都定义为 const,并返回 size_t 类型的值。 表 8-4 函 数 说 明 find_first_of( char ch, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始第一次出现的字符 ch,并返回发现字 符的索引位置值,类型为 size_t。如果省略第二个实参,offset 的默认值就为 0 find_first_of( const char* pstr, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始第一次出现的以空字符结尾的字符串 pstr 中的任何字符,并返回发现字符的索引位置值,类型为 size_t。如果省略第 二个实参,offset 的默认值就为 0 find_first_of( const char* pstr, size_t offset, size_t count) 在 string 对象中搜索从 offset 索引位置开始第一次出现的以空字符结尾的字符 串 pstr 中的前 count 个字符中的任何字符,并返回发现字符的索引位置值,类 型为 size_t find_first_of( const string& str, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始第一次出现的字符串 str 中的任何字 符,并返回发现字符的索引位置值,类型为 size_t。如果省略第二个实参,offset 的默认值就为 0 find_last_of( char ch, size_t offset=npos) 在 string 对象中向后搜索从 offset 索引位置开始最后一次出现的字符 ch,并返回 发现该字符的索引位置值,类型为 size_t。如果省略第二个实参,offset 的默认值 就为字符串的末尾字符 npos find_last_of( const char* pstr, size_t offset=npos) 在 string 对象中向后搜索从 offset 索引位置开始最后一次出现的以空字符结尾的 字符串 pstr 中的任何字符,并返回发现该字符的索引位置值,类型为 size_t。如 果省略第二个实参,offset 的默认值就为字符串的末尾字符 npos find_last_of( const char* pstr, size_t offset, size_t count) 在 string 对象中向后搜索从 offset 索引位置开始最后一次出现的以空字符结尾的 字符串 pstr 中的前 count 个字符,并返回发现该字符的索引位置值,类型为 size_t find_last_of( const string& str, size_t offset=npos) 在string 对象中向后搜索从 offset 索引位置开始最后一次出现的字符串 str 中的任 何字符,并返回发现该字符的索引位置值,类型为 size_t。如果省略第二个实参, offset 的默认值就为字符串的末尾字符 npos 对于 find_first_of()和 find_last_of()函数的所有版本,如果没有发现匹配的字符,就会返回 string::npos。 使用与上一个代码片段中相同的字符串,可以看到 find_last_of()函数对字符串“had”所执行的 第 8 章 深入理解类 359 搜索。 size_t count(0); size_t offset(string::npos); while(true) { offset = str.find_last_of(substr, offset); if(string::npos == offset) break; --offset; ++count; } cout << endl << " Characters from the string \"" << substr << "\" were found " << count << " times in the string above." << endl; 这次从字符串末尾索引位置 string::npos 开始后向搜索,因为这是默认开始位置。该代码片段的 输出为: The string to be searched is: Smith, where Jones had had "had had", "had had" had. "Had had" had had the examiners' approval. Characters from the string "had" were found 38 times in the string above. 结果应当不出意料。记住,我们正在搜索字符串 str 中出现的“had”中的任何字符。“Had”和 “had”单词中有 32 个,其他单词中有 6 个。因为我们在沿着字符串后向搜索,因此当发现一个字 符时,递减循环内的 offset。 最后一组搜索函数是 find_first_not_of()和 find_last_not_of()函数的各个版本,如表 8-5 所示。表 中的所有函数都定义为 const,并返回 size_t 类型的值。 表 8-5 函 数 说 明 find_first_not_of( char ch, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始第一次出现的不是字符 ch 的字符, 并返回发现字符的索引位置值,类型为 size_t。如果省略第二个实参,offset 的默认值就为 0 find_first_not_of( const char* pstr, size_t offset=0) 在 string 对象中搜索从 offset 索引位置开始第一次出现的不在以空字符结尾的 字符串 pstr 中的字符,并返回发现字符的索引位置值,类型为 size_t。如果省 略第二个实参,offset 的默认值就为 0 find_first_not_of( const char* pstr, size_t offset, size_t count) 在 string 对象中搜索从 offset 索引位置开始第一次出现的不在以空字符结尾的 字符串 pstr 中的前 count 个字符中的任何字符,并返回发现字符的索引位置值, 类型为 size_t find_first_not_of( const string& str, size_t offset=0) 在string 对象中搜索从 offset 索引位置开始第一次出现的不在字符串 str中的任 何字符,并返回发现字符的索引位置值,类型为 size_t。如果省略第二个实参, offset 的默认值就为 0 find_last_not_of( char ch, size_t offset=npos) 在 string 对象中向后搜索从 offset 索引位置开始最后一次出现的不是字符 ch 的字符,并返回发现该字符的索引位置值,类型为 size_t。如果省略第二个实 参,offset 的默认值就为字符串的末尾字符 npos Visual C++ 2012 入门经典(第 6 版) 360 (续表) 函 数 说 明 find_last_not_of( const char* pstr, size_t offset=npos) 在 string 对象中向后搜索从 offset 索引位置开始最后一次出现的不在以空字符 结尾的字符串 pstr 中的任何字符,并返回发现该字符的索引位置值,类型为 size_t。如果省略第二个实参,offset 的默认值就为字符串的末尾字符 npos find_last_not_of( const char* pstr, size_t offset, size_t count) 在 string 对象中向后搜索从 offset 索引位置开始最后一次出现的不在以空字符 结尾的字符串 pstr 中的前 count 个字符中的字符。该函数返回发现该字符的索 引位置值,类型为 size_t find_last_not_of( const string& str, size_t offset=npos) 在string 对象中向后搜索从 offset 索引位置开始最后一次出现的不在字符串 str 中的任何字符,并返回发现该字符的索引位置值,类型为 size_t。如果省略第 二个实参,offset 的默认值就为字符串的末尾字符 npos 对于前面的这些搜索函数,如果没有搜索到匹配的字符,那么将返回 string::npos。这些函数有 很多用法,通常用来在字符串中查找可能由各种字符隔开的令牌(token)。例如,用空格和标点符号 隔开的单词组成的文本,因此,可以用这些函数在文本块中查找单词。下面举例说明其工作过程。 试一试:排序文本中的单词 本例将读一个文本块,然后提取一些单词,并以升序输出它们。在此将使用相当低效的冒泡排 序函数,这在 Ex8_14 中可以看到。在第 10 章将使用一个好用得多的库函数来排序,不过在使用此 函数前需要先了解一些别的知识。该程序也会计算每个单词出现的次数,并输出各个单词的个数。 因此,这样的分析称为词语搭配(collocation)。代码如下: // Ex8_15.cpp // Extracting words from text #include #include #include using std::cin; using std::cout; using std::endl; using std::ios; using std::setiosflags; using std::resetiosflags; using std::setw; using std::string; // Sort an array of string objects string* sort(string* strings, size_t count) { bool swapped(false); while(true) { for(size_t i = 0 ; i < count-1 ; i++) { if(strings[i] > strings[i+1]) { swapped = true; strings[i].swap(strings[i+1]); 第 8 章 深入理解类 361 } } if(!swapped) break; swapped = false; } return strings; } int main() { const size_t maxwords(100); string words[maxwords]; string text; string separators(" \".,:;!?()\n"); size_t nwords(0); size_t maxwidth(0); cout << "Enter some text on as many lines as you wish." << endl << "Terminate the input with an asterisk:" << endl; getline(cin, text, '*'); size_t start(0), end(0), offset(0); // Record start & end of word & offset while(true) { // Find first character of a word start = text.find_first_not_of(separators, offset); // Find non-separator if(string::npos == start) // If we did not find it, we are done break; offset = start + 1; // Move past character found // Find first separator past end of current word end = text.find_first_of(separators,offset); // Find separator if(string::npos == end) // If it's the end of the string { // current word is last in string offset = end; // We use offset to end loop later end = text.length(); // Set end as 1 past last character } else offset = end + 1; // Move past character found words[nwords] = text.substr(start, end-start); // Extract the word // Keep track of longest word if(maxwidth < words[nwords].length()) maxwidth = words[nwords].length(); if(++nwords == maxwords) // Check for array full { cout << endl << "Maximum number of words reached." << endl << "Processing what we have." << endl; break; } if(string::npos == offset) // If we reached the end of the string break; // We are done } Visual C++ 2012 入门经典(第 6 版) 362 sort(words, nwords); cout << endl << "In ascending sequence, the words in the text are:" << endl; size_t count(0); // Count of duplicate words // Output words and number of occurrences for(size_t i = 0 ; i < nwords ; i++) { if(0 == count) count = 1; if(i < nwords-2 && words[i] == words[i+1]) { ++count; continue; } cout << setiosflags(ios::left) // Output word left-justified << setw(maxwidth+2) << words[i]; cout << resetiosflags(ios::right) // and word count right-justified << setw(5) << count < < endl; count = 0; } cout << endl; return 0; } 下面是该程序的部分输出: Enter some text on as many lines as you wish. Terminate the input with an asterisk: I sometimes think I'd rather crow And be a rooster than to roost And be a crow. But I dunno. A rooster he can roost also, Which don't seem fair when crows can't crow Which may help some. Still I dunno.* In ascending sequence, the words in the text are: A 1 And 2 But 1 I 3 I'd 1 Still 1 Which 2 a 2 also 1 be 2 can 1 can't 1 crow 3 crows 1 don't 1 dunno 2 第 8 章 深入理解类 363 fair 1 he 1 help 1 may 1 rather 1 roost 2 rooster 2 seem 1 some 1 sometimes 1 than 1 think 1 to 1 when 1 示例说明 在本示例中使用 getline()从 cin 中读取输入,将终止字符指定为一个星号。这样允许输入任意行 代码。从字符串对象 text 的输入中提取单个单词,并存储在 words 数组中。这是通过 while 循环完 成的。 从 text 中提取单词的第一步是找到单词的第一个字符的索引位置: start = text.find_first_not_of(separators, offset); // Find non-separator if(string::npos == start) // If we did not find it, we are done break; offset = start + 1; // Move past character found 调用 find_first_not_of()函数返回位置 offset 中第一个不是 separators 中的字符之一的字符的索引 位置。这里可以使用 find_first_of()函数来搜索 A~Z、a~z 中的任何字符来得到同样的结果。当提取 了最后一个单词后,搜索会到达字符串的末尾,而没有发现任何匹配的字符,因此通过比较返回的 值与 string::npos 来进行测试。如果它是字符串的末尾,就提取所有单词,因此退出循环。在任何其 他情况下,就在发现的字符后面一个字符处设置 offset。 下一步是搜索任何分隔符字符: end = text.find_first_of(separators,offset); // Find separator if(string::npos == end) // If it's the end of the string { // current word is last in string offset = end; // We use offset to end loop later end = text.length(); // Set end as 1 past last character } else offset = end + 1; // Move past character found 这段代码从索引位置 offset 处搜索任何分隔符,这是单词的第一个字符后面的字符,因此通常 将会发现分隔符是单词的最后一个字符后面的字符。当单词是文本中的最后一个词,而且该单词的 最后一个字符后面没有分隔符时,函数就会返回 string::npos,因此我们这样处理此类情况:将 end 设置为字符串中最后一个字符后面的字符,并将 offset 设置为 string::npos。以后在提取当前单词之 后的循环中会测试 offset 变量,来判断是否应结束循环。 单词的提取并不难: Visual C++ 2012 入门经典(第 6 版) 364 words[nwords] = text.substr(start, end-start); // Extract the word substr()函数在 text 中从 start 处的字符开始提取 end-start 个字符。单词的长度是 end-start,因为 start 是第一个字符,而 end 是单词中的最后一个字符后面的那个字符。 while 循环体的其余部分以前面介绍的方式跟踪最大单词长度,检查字符串结束条件,并检查 words 数组有没有满。 这些单词在一个 for 循环中输出,它们在 words 数组中的所有元素上迭代。循环中的 if 语句用 来计数重复的单词: if(0 == count) count = 1; if(i < nwords-2 && words[i] == words[i+1]) { ++count; continue; } count 变量记录重复的单词个数,因此它的最小值总是为 1。在循环的末尾,当写出一个单词和 它的计数时,将 count 设置为 0。它充当一个指示器,表示开始计数新的单词,因此当 count 为 0 时, 第一个 if 语句将它设置为 1,否则保留当前值。 第二个 if 语句检查下一个单词与当前单词是否相同,如果相同,则递增 count,并跳过当前循环 迭代的其余部分。该机制用 count 累计单词的重复次数。循环条件也检查索引 i 是否小于 nwords–2, 若当前单词是数组中的最后一个单词,就不用检查下一个单词。因此,当下一个单词与当前单词不同, 或者当前单词是数组中的最后一个单词时,仅输出一个单词和它的计数。 for 循环中的最后一步是输出一个单词和它的计数: cout << setiosflags(ios::left) // Output word left-justified << setw(maxwidth+2) << words[i]; cout << resetiosflags(ios::right) // and word count right-justified << setw(5) << count << endl; count = 0; 该输出语句将单词在比最长单词长两个字符的字段宽度中左对齐。计数输出是在宽度为 5 的字 段宽度中右对齐。 8.12 小结 本章介绍了定义类以及创建和使用类对象的基础知识,还学习了如何在类中重载运算符,以允 许将运算符应用于类对象。 8.13 练习 1. 定义一个类来表示估计的整数,如“about 40”。整数值可以被认为是精确的或估计的,因此 这里的类应该有一个值类型的数据成员和一个“estimation”标志。该标志的状态将影响算术操作, 第 8 章 深入理解类 365 因此“2*about 40”应该是“about 80”。变量的状态应该可以在“estimated”和“exact”之间切换。 为该类提供一个或多个构造函数。重载+运算符,以便在算术表达式中使用这些整数。+运算符 应该是全局函数还是成员函数?需要赋值运算符吗?提供一个 Print()成员函数,以便将这些整数显 示出来,输出时以前导字符“E”表示设置“estimation”标志。编写一个程序来测试这个类的工作 情况,特别要检查“estimation”标志的设置是否正确。 2. 实现一个简单的字符串类,其中两个私有数据成员是一个 char*字符串和一个整数长度。提 供接受 const char*类型实参的构造函数,并实现复制构造函数、赋值运算符和析构函数。验证这个 类能否正常工作。我们将发现,使用 cstring 头文件中的字符串函数最为简单。 3. 练习 2 中的字符串类还需要添加哪些构造函数?列出清单并编写代码。 4. (高级题)前面实现的类能够正确处理下面的情形吗? string s1; ... s1 = s1; 如果不能,那么应该如何修改? 5. (高级题)为前面的类重载+和+=运算符,以支持字符串的连接。 6. 修改第 7 章练习 5 的栈示例,使栈的大小可以在构造函数中指定并动态分配。还需要添加什 么?测试新类的工作情况。 7. 写一个程序,使用在 string 头文件中声明的 string 类,从键盘读入一个任意长度的文本字符 串。然后该程序应提示输入一个或多个出现在输入文本中的单词。不管大小写,输入文本中出现的 选中单词都应当用与该单词中的字母个数相同的星号替换。只替换完整的单词,因此如果字符串是 "Our friend Wendy is at the end of the road.",选中的单词是"end",则结果应为"Our friend Wendy is at the *** of the road",而不是"Our fri*** W***y is at the *** of the road"。 8. 编写一个类 CTrace,使用该类可以在程序运行时显示何时进入代码块及何时退出代码块,例 如产生类似下面这样的输出: function 'f1' entry 'if' block entry 'if' block exit function 'f1' exit 9. 是否有一种方法可以自动控制练习 8 输出信息的缩进,使输出如下所示? function 'f1' entry 'if' block entry 'if' block exit function 'f1' exit 8.14 本章主要内容 本章主要内容如表 8-6 所示。 Visual C++ 2012 入门经典(第 6 版) 366 表 8-6 主 题 概 念 析构函数 对象是由析构函数销毁的。为了销毁包含在堆上分配的成员的对象,本地 C++类中必须定义 析构函数,因为默认的析构函数不能完成这项任务 默认复制构造函数 如果没有为本地 C++类定义复制构造函数,则编译器将自动提供一个默认的复制构造函数。默 认复制构造函数不能正确处理包含在空闲存储器上分配的数据成员的类对象 定义复制构造函数 当在本地 C++类中自定义复制构造函数时,必须使用引用形参 运算符重载 为了提供类对象所特有的动作,可以重载大多数基本运算符。实现自定义类的运算符函数时, 应该与基本运算符的常规意义一致 类中的赋值运算符 如果没有为类定义赋值运算符,则编译器将提供默认的版本。与复制构造函数一样,默认的 赋值运算符不能正确地处理包含在空闲存储器上分配的数据成员的类对象 在堆上分配内存的类 对于包含 new 操作符分配的成员的类来说,必须提供析构函数、复制构造函数和赋值运算符 联合 联合机制允许两个或多个变量占用内存中相同的位置 string 类 标准库中的 string 类提供了一种功能强大的处理程序中的字符串的方式 类模板 类模板用来创建结构相同的类,但支持不同的数据类型 类模板形参 可以定义拥有多个形参的类模板,形参甚至可以是常量值而非类型 移动语义 可以使用 utility 头文件声明的 std::move()函数,将 lvalue 或 rvalue 转换为 rvalue,而无需复制。 这样就可以在合适时移动而不是复制对象,避免不必要的复制开销 完美转发 utility 头文件声明的 std::forward()模板函数支持完美转发,它允许把实参传递给另一个函 数时,在带有 rvalue 引用实参的模板函数中避免不必要的复制操作 组织代码 应该将程序的定义放入.h 文件,将程序的可执行代码(即函数定义)放入.cpp 文件,然后使用 #include 指令将.h 文件合并到.cpp 文件中 Windows 编程的概念 本章要点 ● 窗口的基本结构 ● Windows API 的概念和用法 ● Windows 消息的概念,以及如何处理 Windows 消息 ● Windows 程序中常用的符号 ● Windows 程序的基本结构 ● 如何使用 Windows API 创建简单的程序,以及该程序的工作原理 ● Microsoft Foundation Classes ● 基于 MFC 的程序的基本元素 本章将学习 C++中与所有 Windows 程序有关的基本概念。首先将开发一个直接使用 Windows 操作系统 API 的简单示例。 该示例将使我们能够理解 Windows 应用程序的后台工作原理,这有助于 我们使用 Visual C++提供的更高级的功能开发应用程序。接着将介绍使用 Microsoft Foundation Classes(MFC,它封装了 Win32 功能)创建 Windows 程序的过程。 11.1 Windows 编程基础 Windows API 称为 WinAPI 或 Win32,后者有点有时,因为现在可以使用 Windows 的 64 位版本 了。使用 Windows API 开发应用程序,需要自始至终地编写代码,构成应用程序 GUI 的所有元素都 必须调用操作系统函数,以编程方式创建。在 MFC 应用程序中,可以使用一组标准类,它们把我 们与 Windows API 隔离开,编码也容易得多。在 GUI 构建方面也提供了一些帮助,可以在对话框窗 体上以图形方式组合控件,只需要对程序与用户之间的交互作用进行编程;但是,仍然要编写大量 的代码。 直接使用 Windows API 是最费力的开发应用程序的方法,所以本书不打算详细探讨该主题。不 过,我们将创建一个基本的 Windows API 应用程序,以理解所有 Windows 应用程序为了与操作系统 11 第 章 Visual C++ 2012 入门经典(第 6 版) 504 协作而都要使用的这种机制的后台原理。当然,使用 C++开发不需要 Windows 操作系统的应用程序 也可以,游戏程序有时就采用这种方法。许多游戏程序都使用 DirectX,这是一个 Windows 专用的 图形库,虽然该方法本身是很有趣的主题,但需要整本书才能进行适当的论述,因此本书不打算进 一步讨论该主题。 在进入本章的示例之前,我们将复习用来描述应用程序窗口的术语。在第 1 章中创建过一个 连一行代码也没有编写的 Windows 程序,下面就使用该程序生成的窗口来说明构成窗口的各种 元素。 11.1.1 窗口的元素 读者必定已经熟悉 Windows 程序的用户界面的大多数主要元素。但无论如何,这里都要一一讲 解这些元素,目的只是为了确保对这些术语的意义有相同的理解。理解窗口元素意义的最好方法是 看一个窗口。带注释的由第 1 章示例生成的窗口如图 11-1 所示。 MDI 父窗口 状态栏 MDI 子窗口 菜单栏 子窗口的标题栏文本 子窗口的工作区 应用程序窗口图标 大小调整手柄 父窗口工作区 工具栏 工具栏按钮 子窗口图标 菜单 应用程序窗口 的标题栏文本 关闭 按钮 最大化 按钮 最小化 按钮菜单项 图 11-1 该示例实际上生成了两个窗口。带菜单和工具栏的较大窗口是应用程序主窗口或父窗口,较小 的窗口是此父窗口的子窗口。虽然可以在不关闭父窗口的情况下,通过双击子窗口左上角的标题栏 图标,或者单击子窗口右上角的“关闭”按钮,将子窗口关闭,但关闭父窗口也将自动关闭子窗口, 这是因为子窗口为父窗口所拥有,依赖于父窗口才能存在。通常,一个父窗口可以有许多子窗口, 稍后将看到这种情形。 典型窗口的最基本组成部分是边框、标题栏 (显示用户提供给窗口的名称 )、标题栏图标 (位于标 题栏左端)和工作区 (窗口中心未被标题栏或边框使用的区域 )。在 Windows 程序中,所有这些元素都 可以自由创建。正如即将看到的那样,我们只需要为标题栏提供一些文本。 边框定义了窗口的边界,它可以是固定的或可调整的。如果边框是可调整的,就可以拖动边框 来改变窗口大小。窗口还可以拥有调整手柄,使用这种手柄可以改变窗口大小。如果愿意,那么可 以在定义窗口时修改边框的行为和外观。大多数窗口还有位于窗口右上角的最大化、最小化和关闭 按钮。这几个按钮允许用户将窗口扩大到全屏、缩小为图标或关闭。 第 11 章 Windows 编程的概念 505 单击标题栏图标时, 将出现一个用于更改或关闭窗口的标准菜单—— 称作系统菜单或控制菜单。 右击窗口标题栏时,也会出现系统菜单。虽然该图标是可选的,但最好总是在程序生成的任何主窗 口中包括标题栏图标。当调试过程中程序工作不正常时,标题栏图标可以提供一种非常方便的关闭 程序的方法。 工作区是窗口的组成部分,我们通常希望程序在这里显示文本或图形。为此,在工作区中处理 的方式与图 7-1 中庭园的方式完全相同。工作区左上角的坐标是 (0,0),x 坐标从左向右增加, y 坐 标从上向下增加。 菜单栏是窗口的可选组件,但菜单可能是最常用的控制应用程序的方式。菜单栏中的每个菜单 都会在单击它时显示菜单项的下拉列表。菜单的内容和窗口中显示的许多对象的物理外观—— 如图 11-1 中工具栏的图标、 光标等, 都是由资源文件定义的。 当开始编写一些更复杂的 Windows 程序时, 将了解到更多的资源文件。 ribbon 是菜单栏的替代方式。 Microsoft Word 和 Microsoft Excel 的最新版本把 ribbon 提供为在应 用程序功能中导航的主要机制。MFC 还提供了很多创建 ribbon 的类,但这里不介绍它们。 工具栏提供的一组图标通常是作为最常用的一些菜单项的替代方法。因为图标可以给出所提供 功能的图示线索,所以经常可以使程序的使用更容易、更快捷。 在进一步介绍之前,这里为防止误解再对术语做一些说明—— 这是我们应该知道的。用户往往 认为窗口就是屏幕上显示的、有边框的对象—— 这种看法当然不错,但这种对象只是窗口的一种。 在 Windows 中,窗口是覆盖所有实体的通用术语。事实上,几乎任何可显示的实体都是窗口,例如, 对话框是窗口,各个工具栏和可停靠的菜单栏也都是窗口。本书通常将使用按钮、对话框等能够说 明对象种类的术语来引用对象,但需要牢牢记住它们也是窗口,因为可以对这些对象做一些对常规 窗口做的事情—— 例如,可以在按钮上绘图。 11.1.2 Windows 程序与操作系统 我们编写的 Windows 程序是在 Windows 操作系统的控制下运行的,它们不能直接处理硬件, 与外部的所有通信都必须通过 Windows 进行。使用 Windows 程序时,主要是与 Windows 交互,然 后由 Windows 与应用程序通信。如果说 Windows 程序是狗尾巴, Windows 就是那条狗;我们的程 序仅当得到 Windows 发出的摇摆命令时才能摇摆。 之所以如此,有很多原因。首先,因为程序可能与其他可以同时执行的程序共享计算机,所以 Windows 必须拥有首要的控制权来管理机器资源的共享。 如果允许一个应用程序在 Windows 环境中 拥有首要控制权,那么由于需要为其他程序的运行提供可能性,将不可避免地使编程问题变得更加 复杂;而且计划给其他应用程序的信息也可能丢失。需要 Windows 进行控制的第二个原因在于 Windows 体现了一种标准的用户界面,需要负责实施这种标准。只能使用 Windows 提供的工具在屏 幕上显示信息,而且只能在经过授权的情况下这样做。 11.1.3 事件驱动型程序 在第 1 章已经知道, Windows 程序是事件驱动的,因此 Windows 程序要等待某个事件发生。 Windows 应用程序所需的重要的代码部分专门用于处理外部用户动作引发的事件,但与应用程序没 有直接关系的活动仍然可能要求执行大量的程序代码。例如,如果用户将另一个应用程序的活动窗 口拖到我们的程序旁边,并且覆盖了我们的应用程序窗口中的部分工作区,则被覆盖的应用程序需 Visual C++ 2012 入门经典(第 6 版) 506 要重画这部分窗口。 11.1.4 Windows 消息 Windows 应用程序中的事件指的是用户单击鼠标、按下某个按键或某个定时器归零。 Windows 操作系统将每个事件记录在一条消息中,并将该消息放入目标程序的消息队列中。因此, Windows 消息只是与某个事件有关的数据记录,而某个应用程序的消息队列只是等待该应用程序处理的消息 序列。通过发送消息, Windows 可以告诉程序某件事情需要完成,或者某些信息已经可用,或者某 个像鼠标单击这样的事件已经发生。如果程序是以适当的方式组织的,那么将以适当的方式响应消 息。有许多不同种类的消息,而且这些消息可能非常频繁地出现—— 例如,在拖动鼠标时每秒出现 许多次。 Windows 程序必须包含专门处理这些消息的函数。 该函数经常称作 WndProc()或 WindowProc(), 然而该函数不必拥有特定的名称,因为 Windows 是通过提供的函数指针访问该函数的。这样,给程 序发送消息就归结为 Windows 调用提供的通常名为 WindowProc()的函数,并借助于给该函数传递的 实参给程序传递任何必要的数据。在相应的 WindowProc()函数内,编程人员应当负责根据提供的数 据,确定消息的意义以及应该采取的动作。 但是,不必编写处理所有消息的代码。可以筛选出程序所关心的消息,以任何需要的方式处理这 些消息,并将其余消息回传给 Windows。通过调用 Windows 提供的标准函数 DefWindowProc() —— 该 函数提供默认的消息处理功能,将消息回传给 Windows。 11.1.5 Windows API 任何 Windows 应用程序与 Windows 本身之间的所有通信,都要使用 Windows 应用程序编程接 口,也称作 Windows API。该接口由多达数百个函数组成—— 它们是 Windows 操作系统提供的标准 函数,可以提供应用程序与 Windows 相互通信的方法。 Windows API 是在 C 还是主要通用语言的年 代开发的,很久之后 C++才出现,因此经常用来在 Windows 和应用程序之间传递数据的是结构而不 是类。 Windows API 覆盖了 Windows 与应用程序之间通信的所有方面。 因为 API 中函数的数量如此之 多,所以在自然状态下使用这些函数可能非常困难;实质上,仅仅理解它们的功能都是一项艰苦的 工作。这正是 Visual C++ 2010 使应用程序开发人员的生活变得非常轻松的地方。 Visual C++在某种 程度上对 Windows API 进行了包装,以面向对象的方式重新组织了这些 API 函数,并提供了更容易 的、在C++中以更多默认功能使用该接口的方法。 这种包装采取的形式是 Microsoft Foundation Classes —— 即 MFC。 Visual C++还提供了许多 Application Wizard,这些向导用来创建各种基本的应用程序, 包括 MFC 应用程序。 Application Wizard 可以生成完整的、可工作的应用程序,其中包括基本的 Windows 应用 程序所需的所有样板代码,只需要为特定目的定制该应用程序即可。第 1 章的示例说明了在完全不 需要编写任何代码的情况下, Visual C++能够提供多大程度的功能性。当使用 Application Wizard 编 写一些更实用的示例时,将更详细地对此进行讨论。 11.1.6 Windows 数据类型 Windows 定义了许多用来在 Windows API 中指定函数的形参类型和返回类型的数据类型。这些 第 11 章 Windows 编程的概念 507 Windows特有的类型还传播到了 MFC定义的函数中。 这些Windows类型的每一种都映射为某种 C++ 类型,但由于 Windows 类型和 C++类型之间的映射可能改变,我们应该总是在适用的场合使用 Windows 类型。例如,在过去, Windows 类型 WORD 在一种 Windows 版本中定义为 unsigned short 类型,在另一种 Windows 版本中定义为 unsigned int 类型。在 16 位机器上,这两种类型是等价的; 但在 32 位机器上,它们无疑是不同的。因此,使用 C++类型而非 Windows 类型的任何人都可能遇 到问题。 可以在文档中找到 Windows 数据类型的完整列表,但表 11-1 给出一些可能是最常见的类型。 表 11-1 BOOL 或 BOOLEAN Boolean 变量的值可以是 TRUE 或 FALSE。注意,该类型与值为 true 或 false 的 C++类型 bool 不同 BYTE 8 位字节 CHAR 8 位字符 DWORD 32 位无符号整数,对应于 C++中的 unsigned long 类型 HANDLE 指向某个对象的句柄,是 32 位的整数值,记录着该对象在内存中的位置。当以 64 位模 式编译时,则是 64 位整数值 HBRUSH 指向某个画笔的句柄,画笔用来以颜色填充某块区域 HCURSOR 指向某个光标的句柄 HDC 指向某种设备上下文的句柄—— 设备上下文是允许我们在窗口上绘图的对象 HINSTANCE 指向某个实例的句柄 LPARAM 消息的形参 LPCTSTR 如果定义了_UNICODE,则为 LPCWSTR,否则为 LPCSTR LPCWSTR 指向某个由 16 位字符构成的、以空字符终止的字符串常量的指针 LPCSTR 指向某个由 8 位字符构成的、以空字符终止的字符串常量的指针 LPHANDLE 指向某个句柄的指针 LRESULT 处理消息产生的有符号值 WORD 16 位无符号整数,对应于 C++中的 unsigned short 类型 本书将介绍任何其他需要在示例中使用的 Windows 类型。 Windows API 函数原型使用的所有 Windows 类型都包含在 windows.h 头文件中, 因此在整合基本的 Windows 程序时需要包含该头文件。 11.1.7 Windows 程序中的符号 在许多 Windows 程序中,变量名的前缀都能够指出该变量容纳的数值类型以及该变量的用法。 这样的前缀很多,而且经常组合使用。例如,前缀 lpfn 表示指向某个函数的 long 类型指针。我们可 能遇到的部分前缀如表 11-2 所示。 表 11-2 前 缀 意 义 b BOOL 类型的逻辑变量,等价于 int by unsigned char 类型,占用一个字节 c char 类型 Visual C++ 2012 入门经典(第 6 版) 508 (续表) 前 缀 意 义 dw DWORD 类型,等价于 unsigned long fn 函数 h 用来引用某种对象的句柄 i int 类型 l long 类型 lp long 类型的指针 n unsigned int 类型 p 指针 s 字符串 sz 零终止的字符串 w WORD 类型,等价于 unsigned short 这些前缀的这种用法称为匈牙利表示法。引入这种表示法的目的是为了最大限度地降低因为对 变量的定义方法和预定用法有不同解释而误用变量的可能性。这样的误解在 C++的前身—— C语言 中是很容易发生的。使用 C++及其强类型检查功能,不需要在表示法方面作出如此特殊的努力就能 避免误解的问题。编译器总是将程序中的类型不一致性标记为错误,许多折磨早期 C 程序的此类错 误在 C++中都不可能发生。 另一方面,匈牙利表示法仍然有助于使程序更易于理解,尤其是在处理大量作为 Windows API 函数实参的不同类型变量的时候。 因为 Windows 程序仍然是用 C 语言编写的, 当然还因为 Windows API 函数的形参仍然是使用匈牙利表示法定义的,所以仍然十分广泛地使用这种方法。 我们可以自行决定希望在多大程度上使用匈牙利表示法,因为是否使用该表示法绝不是强制性 的。可以选择完全不使用该表示法。但无论如何,如果能够了解该表示法的使用方法,那么将更易 于理解 Windows API 函数参数的作用。但有一点需要说明,以防误解:随着 Windows 的不断发展, 有些 API 函数参数的类型发生细小的变化,但使用的变量名仍然相同。因此,某些变量的前缀在指 示变量类型方面可能不是完全正确。 11.2 Windows 程序的结构 就最简单的仅使用 Windows API 的 Windows 程序而言,需要编写两个函数。一个是 WinMain() 函数,程序的执行是从这里开始的,基本的程序初始化工作也是在这里完成的。另一个是 WindowProc()函数,该函数是由 Windows 调用的,用来给应用程序传递消息。 Windows 程序的 WindowProc()部分通常较大,因为该函数要响应各种因用户输入而引发的消息,所以应用程序的大 多数专用的代码都在这里。 虽然这两个函数构成了完整的程序,但它们之间没有直接的联系。调用 WindowProc()函数的是 Windows 而非 WinMain()。事实上,WinMain()也是 Windows 调用的。图 11-2 可以说明这种情况。 第 11 章 Windows 编程的概念 509 桌面应用程序 WinMain() Windows API WindowProc() Windows 7/8 程序开 始 消息 图 11-2 WinMain()函数通过调用某些 Windows API 函数与 Windows 通信, WindowProc()函数也是如此。 Windows 桌面应用程序中的集成因子是 Windows 本身,它链接了 WinMain()与 WindowProc()。下面 首先看一下构成 WinMain()和 WindowProc()函数的都有哪些部件,然后再将这些部件组装成一个可 工作的、简单的 Windows 程序示例。 11.2.1 WinMain()函数 WinMain()函数等价于控制台程序中的 main()函数。该函数是执行开始的地方,也是为程序其余 部分执行基本初始化工作的地方。为了允许 Windows 传递数据, WinMain()函数有 4 个形参和一个 int 类型的返回值,其原型如下: int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow ); 在返回类型说明符 int 的后面,有一个函数说明符 WINAPI。WINAPI 是一个 Windows 定义的 宏,将使系统以特定于 Windows API 函数的某种特殊方式处理函数名和实参。这种方式与 C++通常 处理函数的方式不同。具体的细节是不重要的— —这只不过是 Windows 所要求的方式而已,因此只 需要将 WINAPI 宏名称放在由 Windows 调用的函数名前面即可。 Visual C++ 2012 入门经典(第 6 版) 510 Windows 传递给 WinMain()函数的 4 个参数包含着重要的数据: ● hInstance 属于 HINSTANCE 类型,是指向某个实例的句柄—— 这里的实例是正在运行的程 序。句柄是标识某种对象 (这里是应用程序的实例 )的整数值。句柄的实际整数值是多少并不 重要。在任何给定时刻都可能有好几个程序在 Windows 下执行,这就使相同应用程序可能 有若干副本同时在活动,而这种情形需要识别出来。因此, hInstance 句柄标识某个特定的 副本。如果启动某个程序的多个副本,则每个副本都有自己独特的 hInstance 值。正如我们 很快就将看到的那样,句柄还用来标识各种其他事物。 ● hPrevInstance 是从 16 位版本的 Windows 操作系统继承下来的, 我们可以放心地对它置之不 理。在当前版本的 Windows 中,该参数始终为空。 ● lpCmdLine 是指向某个字符串的指针, 该字符串包含启动程序的命令行字符。 该指针允许挑 出可能在命令行中出现的任何参数值。 LPSTR 类型是另一种 Windows 类型,用来指定 32 位(long)的字符串指针,或者当以 64 位模式编译时,则用来指定 64 位的字符串指针。 WinMain()的另一个版本接收 LPWSTR,用于使用 Unicode。 ● nCmdShow 决定着被创建窗口的外观。窗口可以正常显示,也可以最小化显示;例如,程 序的快捷方式可能指定该程序在启动时应该最小化显示。该参数可以是一组固定值之一, 这些值是由像 SW_SHOWNORMAL 和 SW_SHOWMINNOACTIVE 这样的一些符号常量定 义的。此类定义窗口显示方式的常量还有 9 个,它们都以 SW_开始。通常不需要检查 nCmdShow 的值,而是直接将其传递给负责显示应用程序窗口的 Windows API 函数。 程序中的 WinMain()函数需要做以下 4 件事情: ● 告诉 Windows 该程序需要的窗口种类 ● 创建程序窗口 ● 初始化程序窗口 ● 检索属于该程序的 Windows 消息 接下来依次看一看这 4 件事情,然后创建一个完整的 WinMain()函数。 如果确实想要了解调用约定,就查看随 Visual C++提供的文档, 文档中有对调用约定 的描述。WINAPI 定义为__stdcall,将此修饰符置于函数名之前表明使用的是标准 Windows 调用约定。这要求参数以相反的顺序被推入栈,被调用函数结束时清除栈。本章稍后将看 到的 CALLBACK 修饰符也定义为 __stdcall,因此与 WINAPI 是等价的。标准 C++调用约 定由__cdecl 修饰符指定。 如果希望知道指定窗口显示方式的所有其他常量,那么可以通过在 MSDN 库中搜索 WinMain,找到所有可能值的完整列表。可以在 http://msdn2.microsoft.com/en-us/library/ default.aspx 上联机访问 MSDN 库。 第 11 章 Windows 编程的概念 511 1. 指定程序窗口 创建窗口的第一步是定义希望创建的窗口的种类。 Windows 定义了名为 WNDCLASSEX 的一种 特殊 struct 类型,以包含用来指定窗口的数据。存储在该结构实例中的数据定义了一个窗口类,这 个类用来确定窗口的类型。 需要创建一个 WNDCLASSEX 类型的变量, 并给该变量的每个成员赋值。 在填写完这些变量之后,可以将其传递给 Windows(借助于稍后即将看到的一个函数 )来注册这个类。 当完成注册之后,无论何时需要创建该类的窗口,都可以命令 Windows 查找已经注册过的窗口类。 WNDCLASSEX 结构的定义如下所示: struct WNDCLASSEX { UINT cbSize; // Size of this object in bytes UINT style; // Window style WNDPROC lpfnWndProc; // Pointer to message processing function int cbClsExtra; // Extra bytes after the window class int cbWndExtra; // Extra bytes after the window instance HINSTANCE hInstance; // The application instance handle HICON hIcon; // The application icon HCURSOR hCursor; // The window cursor HBRUSH hbrBackground; // The brush defining the background color LPCTSTR lpszMenuName; // A pointer to the name of the menu resource LPCTSTR lpszClassName; // A pointer to the class name HICON hIconSm; // A small icon associated with the window }; 构造 WNDCLASSEX 类型对象的方式与前面讨论结构时所看到的方式相同,例如: WNDCLASSEX WindowClass; // Create a window class object 现在可以填写 WindowClass 成员的值。这些成员默认的访问权限都是公有的, 因为 WindowClass 是一个 struct。使用 sizeof 操作符时,为这个 struct 的 cbSize 成员赋值变得相当简单: WindowClass.cbSize = sizeof(WNDCLASSEX); 这个 struct 的 style 成员决定着窗口行为的各个方面;特别是该成员决定着在什么条件下窗口应该 重画。可以从该成员的许多选项值中进行选择,其中每个选项都是由以 CS_开始的符号常量定义的。 如果需要两个或多个选项, 那么可以使用按位或运算符 | 组合这些常量, 从而产生一个复合值。 例如: WindowClass.style = CS_HREDRAW | CS_VREDRAW; 选项 CS_HREDRAW 告诉 Windows,如果窗口的水平宽度改变, 则重画该窗口;而 CS_VREDRAW 指出,如果窗口的垂直高度改变,那么重画相应的窗口。在前面这条语句中,我们选择在这两种情况 下都重画窗口。因此,只要用户更改了窗口的宽度或高度, Windows 就给程序发送一条指出应该重画 如果在从 http://msdn2.microsoft.com/en-us/library 上找到的 MSDN 库中搜索 WNDCLASSEX,那么将找到所有可能的供 style 成员使用的常量值。 Visual C++ 2012 入门经典(第 6 版) 512 窗口的消息。每种可能的窗口样式选项都是通过将 32 位字中独特的某个位设置为 1 而定义的,这就 是要使用按位或运算符组合它们的原因。这些表示某种特定样式的位通常称为标志。标志不仅在 Windows 中,而且在 C++中也使用得非常频繁,因为它们是表示并处理非有即无特征或非真即假参数 的有效方法。 成员 lpfnWndProc 存储着指向程序中处理消息的函数(被处理的消息属于创建的窗口)的指针。该 成员名称的前缀表明这是一个指向函数的 long 指针。如果我们也像大多数人那样调用 WindowProc() 函数来处理应用程序的消息,那么应当用下面这条语句初始化该成员: WindowClass.lpfnWndProc = WindowProc; 接下来两个成员 cbClsExtra 和 cbWndExtra 允许我们请求 Windows 在内部为特别用途提供额外 空间。例如,当需要关联其他数据与窗口的每个实例,以参与各个窗口实例的消息处理过程时。通 常不需要分配额外的空间,这种情况下必须将 cbClsExtra 和 cbWndExtra 成员设置为 0。 hInstance 成员容纳当前应用程序实例的句柄,因此应该将该成员设置为 Windows 传递给 WinMain()函数的 hInstance 值。 WindowClass.hInstance = hInstance; 成员 hIcon、hCursor 和 hbrBackground 都是句柄,它们依次引用如下对象: ● 最小化时的应用程序 ● 窗口使用的光标 ● 窗口客户区的背景色 如前所述,句柄只不过是用作表示某种事物的 32 位整数 ID,当以 64 位模式编译时则表示 64 位整数 ID。这 3 个成员应当使用 Windows API 函数设置。例如: WindowClass.hIcon = LoadIcon(0, IDI_APPLICATION); WindowClass.hCursor = LoadCursor(0, IDC_ARROW); WindowClass.hbrBackground = static_cast(GetStockObject(GRAY_BRUSH)); 这 3 次函数调用将这 3 个成员设置为标准的 Windows 值。图标是 Windows 提供的默认图标, 光标是大多数 Windows 应用程序使用的标准箭头光标。画笔是用来填充某块区域 (这里是窗口的工 作区)的 Windows 对象。函数 GetStockObject()返回所有原料对象的泛型类型,因此需要将其强制转 换为 HBRUSH 类型。在上面的示例中,该函数返回的是标准灰色画笔的句柄,因此将窗口的背景 色设置为灰色。该函数也可以用来为窗口获得其他标准对象,如字体。也可以将 hIcon 和 hCursor 成员设置为空,那样 Windows 将提供默认的图标和光标。如果将 hbrBackground 设置为空,则该程 序将等待窗口背景的绘制,而只在必要时 Windows 才将绘制消息发送给应用程序。 lpszMenuName 成员应当设置为定义窗口菜单的资源的名称; 如果该窗口没有菜单, 则应当将其 设置为 NULL: WindowClass. lpszMenuName = NULL; 后面将在使用 AppWizard 时介绍菜单资源的创建和使用。 该 struct 的 lpszClassName 成员存储着为标识该特定的窗口类而提供的名称。通常,使用应用程 序的名称为该成员赋值。需要记住该名称,因为在创建窗口时将再次需要它。该成员通常是用下面 的语句设置的: 第 11 章 Windows 编程的概念 513 static LPCTSTR szAppName = _T("OFWin"); // Define window class name WindowClass.lpszClassName = szAppName; // Set class name 此处使用 tchar.h 头文件中的 _T()宏定义 szAppName。如果为该应用程序定义 UNICODE,则将 LPCTSTR 类型定义为 const wchar_t *,反之则定义为 const char*。_T()宏会自动创建正确类型的字 符串。 最后一个成员是 hIconSm,它标识某个与该窗口类相联系的小图标。如果将该成员设置为空, 则 Windows 将搜索与 hIcon 成员相关的小图标并使用。 2. 创建程序窗口 将 WNDCLASSEX 结构的所有成员都设置为所需的值后,下一步是把相关情况告诉 Windows。 可以使用 Windows API 函数 RegisterClassEx()来做这件事。假定 WNDCLASSEX 结构对象是 WindowClass,则相应的语句如下所示: RegisterClassEx(&WindowClass); 很简单,不是吗?只需要给 RegisterClassEx()函数传递该 struct 的地址, Windows 就会提取并记 录所有结构成员的设定值。该过程称为注册窗口类。再次提醒一下,这里的术语“类”是在“分类” 的意义上使用的,与 C++中“类”的概念不同,因此不要混淆两者。应用程序的每个实例都必须确 保注册自己需要的窗口类。 在 Windows 知道我们需要的窗口特性以及为该窗口处理消息的函数是什么之后, 即可创建该窗 口。用于完成该操作的函数是 CreateWindow()。我们已经创建的窗口类确定了应用程序窗口的一般 特性,而传递给 CreateWindow()函数的其他实参将添加一些附加的特性。因为应用程序通常可以有 多个窗口,所以 CreateWindow()函数将返回所创建窗口的句柄。可以存储该句柄,以便稍后能够引 用这个特定的窗口。 有许多 API 调用都要求指定窗口句柄作为参数。 此刻, 可以看一看 CreateWindow() 函数的典型用法。代码如下: HWND hWnd; // Window handle ... hWnd = CreateWindow( szAppName, // the window class name "A Basic Window the Hard Way", // The window title WS_OVERLAPPEDWINDOW, // Window style as overlapped CW_USEDEFAULT, // Default screen position of upper left CW_USEDEFAULT, // corner of our window as x,y... CW_USEDEFAULT, // Default window size, width... CW_USEDEFAULT, // ...and height 0, // No parent window 0, // No menu hInstance, // Program Instance handle 0 // No window creation data ); HWND 类型的变量 hWnd 是指向某个窗口的 32 位整数句柄,或者在 64 位模式中则为 64 位整 数句柄。我们将使用该变量来记录 CreateWindow()函数返回的窗口句柄。给该函数传递的第一个实 参是类名称。 Windows 使用该参数来识别前面在 RegisterClassEx()函数调用中传递的 WNDCLASSEX struct,这样来自该 struct 的信息就能用于窗口的创建过程。 Visual C++ 2012 入门经典(第 6 版) 514 CreateWindow()函数的第二个实参定义标题栏上出现的文本。第三个实参指定该窗口在创建之 后应具有的样式。这里指定的选项 WS_OVERLAPPEDWINDOW 实际上组合了多个选项。该选项 将窗口定义为具有 WS_OVERLAPPED、WS_CAPTION、WS_SYSMENU、WS_THICKFRAME、 WS_MINIMIZEBOX 和 WS_MAXIMIZEBOX 样式。结果是一个计划用作主应用程序窗口的可重叠 窗口,该窗口包括标题栏和粗框架,标题栏上有标题栏图标、系统菜单、最大化按钮和最小化按钮。 对于拥有粗框架的窗口,可以调整其边框的大小。 接下来 4 个实参确定了该窗口在屏幕上的位置和大小。前两个是窗口左上角的屏幕坐标,后两 个定义了窗口的宽度和高度。 CW_USEDEFAULT 值表示希望 Windows 为该窗口分配默认的位置和 大小,它告诉 Windows 沿屏幕向下在层叠位置排列连续的窗口。 CW_USEDEFAULT 仅应用于被指 定为 WS_OVERLAPPED 的窗口。 下一个实参值是 NULL,表明创建的窗口不是子窗口(依赖父窗口的窗口)。如果希望该窗口是 子窗口,则应当将该实参设置为父窗口的句柄。再下来一个实参也是 NULL,它表明不需要菜单。 之后,指定由 Windows 传递给程序的当前程序实例的句柄。最后一个表示窗口创建数据的实参是 NULL,因为在本示例中只需要一个简单的窗口。如果需要创建一个多文档界面 (multiple-document interface,MDI)客户窗口,则最后一个实参应当指向某个与此相关的结构。稍后我们将学习与 MDI 窗口有关的更多内容。 在调用 CreateWindow()函数之后,被创建的窗口现在已经存在,但还没有显示在屏幕上。需要 调用另一个 Windows API 函数将该窗口显示出来: ShowWindow(hWnd, nCmdShow); // Display the window 这里只需要两个实参。第一个实参标识要显示的窗口,它是 CreateWindow()函数返回的句柄。 第二个实参是给 WinMain()传递的 nCmdShow 值,它指出在屏幕上显示窗口的方式。 3. 初始化程序窗口 在调用 ShowWindow()函数之后,该窗口将出现在屏幕上,但仍然没有应用程序的内容,因此 需要使程序在该窗口的工作区中输出信息。可以直接在 WinMain()函数中将某些输出代码放在一起, 但这种方法是最不令人满意的:在这种情况下,操作系统认为工作区的内容不是永久性的。如果希 望保留工作区的内容,则不能仅输出所需内容,然后将其忘之脑后。用户修改窗口的任何动作 (如拖 动边框或整个窗口),通常都需要重画窗口及工作区。 当因任何原因而需要重画工作区时, Windows 将给程序发送一条特定的消息,而 WindowProc() 函数需要以重构窗口的工作区作为响应。因此,最初绘制工作区的最好方法是把绘制工作区的代码 放入 WindowProc()函数,并使 Windows 给程序发送请求重画工作区的消息。 当我们在程序中知道应 该重画窗口(如修改某些内容的时候)时,需要告诉 Windows 发送一条窗口应该重画的消息。 通过调用另一个 Windows API 函数 UpdateWindow(),请求 Windows 给程序发送一条重画窗口 工作区的消息。该函数调用的语句如下: UpdateWindow(hWnd); // Cause window client area to be drawn Windows API 还包括一个 CreateWindowEx()函数,可用来以扩展的样式信息创建 窗口。 第 11 章 Windows 编程的概念 515 该函数只需要一个实参:标识特定程序窗口的窗口句柄 hWnd。该调用的结果是 Windows 给程 序发送一条请求重画工作区的消息。 4. 处理 Windows 消息 最后一项需要 WinMain()完成的任务是处理 Windows 为应用程序排好的消息队列。这么说似乎 有点儿奇怪,因为前面曾经说过需要 WindowProc()函数来处理消息,下面进一步解释。 排队消息与非排队消息 前面介绍的 Windows 消息概念过于简化。事实上有两种 Windows 消息。 一种是被 Windows 放入队列的排队消息, WinMain()函数必须从队列中提取这些消息进行处理。 WinMain()函数中做这件事的代码称为消息循环。 排队消息包括因用户从键盘输入、 移动鼠标以及单 击鼠标按钮而产生的消息。来自定时器的消息和请求重画窗口的 Windows 消息也都是排队消息。 另一种是致使 Windows 直接调用 WindowProc()函数的非排队消息。 大量的非排队消息是作为处 理排队消息的结果产生的。我们在 WinMain()函数内消息循环中所做的事情是从 Windows 为应用程 序排好的消息队列中提取一条消息, 然后请求 Windows 调用 WindowProc()函数来处理该消息。 为什 么 Windows 不能在需要时直接调用 WindowProc()函数呢?当然可以,但只是没有以这种方式工作, 原因与 Windows 对多个同时执行的应用程序的管理方式有关。 消息循环 如前所述,从消息队列中获取消息是使用某种标准机制完成的,该机制在 Windows 编程中称为 消息泵或消息循环。消息循环的代码如下所示: MSG msg; // Windows message structure while(GetMessage(&msg, 0, 0, 0) == TRUE) // Get any messages { TranslateMessage(&msg); // Translate the message DispatchMessage(&msg); // Dispatch the message } 这部分代码涉及处理每条消息的 3 个步骤: ● GetMessage():从队列中检索一条消息。 ● TranslateMessage():对检索的消息执行任何必要的转换。 ● DispatchMessage():使 Windows 调用应用程序的 WindowProc()函数来处理消息。 GetMessage()函数的作用非常重要, 因为它对 Windows 处理多个应用程序的方式具有重大贡献。 接下来详细地看一看这个函数。 GetMessage()函数检索应用程序窗口的消息队列中的某条消息,并将与该消息有关的信息存储 在第一个实参指向的变量 msg。变量 msg 是 MSG 类型的 struct,包含许多没有在这里访问的不同成 员。但为完整起见,下面给出该结构的定义: struct MSG { HWND hwnd; // Handle for the relevant window UINT message; // The message ID WPARAM wParam; // Message parameter (32-bits) LPARAM lParam; // Message parameter (32-bits) DWORD time; // The time when the message was queued Visual C++ 2012 入门经典(第 6 版) 516 POINT pt; // The mouse position }; 前面提到的对匈牙利表示法前缀的误解现在可能在 wParam 成员上成为现实。我们可能认为该 成员属于 WORD 类型(即 16 位无符号整数 ),这种想法在早期的 Windows 版本中是正确的,但现在 该成员属于 WPARAM 类型,它是一个 32 位整数值。 wParam 和 lParam 成员的确切内容取决于消息的种类。 message 成员中的消息 ID 是一个整数值, 它可以是一组在 windows.h 头文件中预定义为符号常量的值之一。普通窗口的消息 ID 都以 WM_开 始,典型的例子如表 11-3 所示。 普通窗口消息覆盖了大量不同的事件, 并且包括与鼠标和菜单事件、 键盘输入以及窗口创建和管理相关的消息。 表 11-3 ID 描 述 WM_PAINT 应该重画窗口 WM_SIZE 已重新调整窗口大小 WM_LBUTTONDOWN 按下鼠标左键 WM_RBUTTONDOWN 按下鼠标右键 WM_MOUSEMOVE 已移动鼠标 WM_CLOSE 应该关闭窗口或应用程序 WM_DESTROY 正在销毁窗口 WM_QUIT 应该终止程序 GetMessage()函数总是返回 TRUE,除非该消息是终止程序的 WM_QUIT(此时返回值是 FALSE),或者发生了错误 (此时返回值是– 1)。因此, while 循环将持续执行,直到产生关闭应用程 序的退出消息或者出现错误状态。在这两种情况下,都需要在 return 语句中将 wParam 值回传给 Windows,来结束程序。 对 GetMessage()函数的调用中,第二个实参是某个窗口的句柄,希望为该窗口获取消息。该参 数可用来单独地为某一个窗口检索消息。如果该参数像此处这样是 0,则 GetMessage()函数将检索 应用程序的所有消息。这是一种简单的检索该程序所有消息的方法,而不管某个应用程序有多少窗 口。它也是最安全的方法,因为我们肯定可以获得应用程序的全部消息。例如,当 Windows 程序的 用户关闭应用程序的窗口时,该窗口是在生成 WM_QUIT 消息之前关闭的。因此,如果仅仅通过给 GetMessage()函数指定窗口句柄来检索消息,则不能检索这条 WM_QUIT 消息,导致程序不能正常 终止。 GetMessage()函数的最后两个实参是两个整数,它们存储希望从队列中检索的消息 ID 的最小值 和最大值,从而可以有选择性地检索消息。该范围通常是用符号常量指定的。例如,使用 WM_ MOUSEFIRST 和 WM_MOUSELAST 作为这两个实参将只选择鼠标消息。 如果这两个实参像本例中 这样都是 0,那么将检索所有消息。 对于为不同于普通窗口的其他窗口类型指定的消息,也有除了 WM 之外的前缀。 第 11 章 Windows 编程的概念 517 多任务 如果没有排队的消息,则 GetMessage()函数不会把控制权返回到程序中。 Windows 允许将执行 权传递给另一个应用程序,仅当队列中有消息时才能从调用 GetMessage()函数获得返回值。 该机制是允许多个应用程序在旧版 Windows 下运行的基础,称作协作式多任务,因为该机制依 赖于并发的应用程序不时放弃对处理器的控制权。在程序调用 GetMessage()函数之后,如果没有需 要程序处理的消息,则系统将执行另一个应用程序,而我们的程序仅当另一个应用程序释放处理器 之后才能获得另一次执行操作的机会。释放处理器的原因可能是调用 GetMessage()函数之后该程序 的消息队列中没有消息,但这不是唯一的可能性。 在当前的 Windows 版本中,操作系统可以在一段时间之后中断某个应用程序,然后将控制权传 递给另一个应用程序。该机制称作抢先式多任务,因为任何情况下都可以中断某个应用程序。但在 抢先式多任务机制下,仍然必须像以前那样在 WinMain()函数中使用 GetMessage()函数编写消息循 环的代码,并为在长时间运行的计算中不时将对处理器的控制权交还给 Windows 预先采取措施 (通 常是使用 API 函数 PeekMessage()完成的 )。如果不这样做,应用程序就可能无法响应出现的重画应 用程序窗口的消息。产生该消息的原因可能与应用程序完全无关—— 例如,关闭相重叠的另一个应 用程序的窗口。 GetMessage()函数概念性的工作过程如图 11-3 所示。 在 while 循环内,首先调用 TranslateMessage()函数,请求 Windows 为与键盘有关的消息做一些 转换工作。然后调用 DispatchMessage()函数,使 Windows 分派该消息—— 换句话说,就是调用程序中 的 WindowProc()函数来处理该消息。 在 WindowProc()函数结束对消息的处理之前, DispatchMessage() 函数不会返回。 WM_QUIT 消息意味着程序应该结束,因此该消息把 FALSE 返回给应用程序,使 消息循环停止。 消息? 否 是 在 msg 中存储 此消息 否 是 返回 FALSE 您的程序 运行另一个 应用程序 GetMessage() Windows 7/8 返回 FALSE 图 11-3 Visual C++ 2012 入门经典(第 6 版) 518 5. 完整的 WinMain()函数 我们已经看过所有需要包含在 WinMain()函数中的代码,因此现在可以将它们汇编成一个完整 的函数: // Listing OFWIN_1 int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASSEX WindowClass; // Structure to hold our window's attributes static LPCTSTR szAppName = L"OFWin"; // Define window class name HWND hWnd; // Window handle MSG msg; // Windows message structure WindowClass.cbSize = sizeof(WNDCLASSEX); // Set structure size // Redraw the window if the size changes WindowClass.style = CS_HREDRAW | CS_VREDRAW; // Define the message handling function WindowClass.lpfnWndProc = WindowProc; WindowClass.cbClsExtra = 0; // No extra bytes after the window class WindowClass.cbWndExtra = 0; // structure or the window instance WindowClass.hInstance = hInstance; // Application instance handle // Set default application icon WindowClass.hIcon = LoadIcon(0, IDI_APPLICATION); // Set window cursor to be the standard arrow WindowClass.hCursor = LoadCursor(0, IDC_ARROW); // Set gray brush for background color WindowClass.hbrBackground = static_cast(GetStockObject(GRAY_BRUSH)); WindowClass.lpszMenuName = 0; // No menu WindowClass.lpszClassName = szAppName; // Set class name WindowClass.hIconSm = 0; // Default small icon // Now register our window class RegisterClassEx(&WindowClass); // Now we can create the window hWnd = CreateWindow( szAppName, // the window class name L"A Basic Window the Hard Way", // The window title WS_OVERLAPPEDWINDOW, // Window style as overlapped CW_USEDEFAULT, // Default screen position of upper left CW_USEDEFAULT, // corner of our window as x,y... CW_USEDEFAULT, // Default window size CW_USEDEFAULT, // .... 0, // No parent window 0, // No menu hInstance, // Program Instance handle 0 // No window creation data ); 第 11 章 Windows 编程的概念 519 ShowWindow(hWnd, nCmdShow); // Display the window UpdateWindow(hWnd); // Cause window client area to be drawn // The message loop while(GetMessage(&msg, 0, 0, 0) == TRUE) // Get any messages { TranslateMessage(&msg); // Translate the message DispatchMessage(&msg); // Dispatch the message } return static_cast(msg.wParam); // End, so return to Windows } 必须实现 WindowProc(),才能使其成为可工作的 Windows 应用程序。我们解释了这段代码后, 就实现该函数。 示例说明 在声明过 WinMain()函数中需要的变量之后,初始化 WindowClass 结构的所有成员,并注册该 窗口类。下一步是调用 CreateWindow()函数,基于传递的实参以及先前使用 RegisterClassEx()函数传 递给 Windows 的 WindowClass 结构所包含的数据,创建供窗口的物理外观使用的数据。对 ShowWindow()函数的调用致使该窗口根据 nCmdShow 指定的模式显示,而 UpdateWindow()函数通 知操作系统应该生成一条重画窗口工作区的消息。 最后,消息循环持续检索该应用程序的消息,直到获得一条 WM_QUIT 消息为止。该消息使 GetMessage()函数返回 FALSE,从而终止循环,而 msg 结构中 wParam 成员的值将在 return 语句中 回传给 Windows。 11.2.2 消息处理函数 除应用程序窗口的通用外观以外, WinMain()函数不包含任何应用程序特有的代码。 使应用程序 以我们希望的方式运转的所有代码都位于程序的消息处理部分—— 即在传递给 Windows 的 WindowClass 结构中标识的 WindowProc()函数。每次分派主应用程序窗口的消息时,都要调用该函 数。因为 Windows 通过函数指针标识 WindowProc()函数,所以可以给该函数使用任意名称,这里继 续称之为 WindowProc()。 本示例相当简单,因此我们将把所有处理消息的代码都放在 WindowProc()这一个函数内。但更 通常的做法是让 WindowProc()函数负责分析给定的消息是什么,以及该消息是供哪个窗口使用的, 然后调用一大堆函数中的一个。在被调用的这些函数中,每个函数只负责处理相关特定窗口的上下 文中某条特定的消息。但在大多数应用程序上下文中,总体的操作顺序以及 WindowProc()函数分析 传入消息的方式都是非常相似的。 1. WindowProc()函数 WindowProc()函数的原型如下: LRESULT CALLBACK WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam); 返回类型是 LRESULT,是一个 Windows 类型,通常等价于 long 类型。因为该函数是 Windows Visual C++ 2012 入门经典(第 6 版) 520 通过指针(该指针是在 WinMain()函数的 WNDCLASSEX 结构中设置的 )调用的,所以需要将该函数 限定为 CALLBACK。前面曾提到过此说明符,其作用与 Windows 定义的 WINAPI 相同, WINAPI 决定着函数实参的处理方式。这里可以使用 WINAPI 替代 CALLBACK,但后者更好地表达出这个 函数的作用。传递给 WindowProc()函数的 4 个实参提供与致使调用该函数的特定消息有关的信息, 它们的意义如表 11-4 所述。 表 11-4 实 参 意 义 HWND hWnd 一个句柄,指向致使该消息发生的事件所在的窗口 UINT message 消息 ID,指出消息类型的 32 位整数值 WPARAM wParam 包含与消息种类有关的其他信息,是 32 位(64 位模式中是 64 位)的值 LPARAM lParam 包含与消息种类有关的其他信息,是 32 位(64 位模式中是 64 位)的值 与传入消息有关的窗口由传递给该函数的第一个实参 hWnd 标识。在本例中,只有一个窗口, 因此可以忽略该参数。 消息是由传递给 WindowProc()的 message 值标识的。 可以对照预定义的符号常量来测试这个值, 其中各个常量表示某种特定的消息。一般的窗口消息都以 WM_开始,典型的示例有 WM_PAINT—— 对 应于重画窗口部分工作区的请求,还有 WM_LBUTTONDOWN—— 表明按下鼠标左键。通过在 MSDN 库中搜索 WM_,可以找到所有这些常量。 2. 解码 Windows 消息 要解码 Windows 发送的消息,通常要基于 message 的值,在 WindowProc()函数中使用 switch 语句来完成。然后,选择希望处理的消息类型就只是为 switch 中的每种情形放上一条 case 语句。这 种 switch 语句的典型结构如下所示: switch(message) { case WM_PAINT: // Code to deal with drawing the client area break; case WM_LBUTTONDOWN: // Code to deal with the left mouse button being pressed break; case WM_LBUTTONUP: // Code to deal with the left mouse button being released break; case WM_DESTROY: // Code to deal with a window being destroyed break; default: // Code to handle any other messages } 每个 Windows 程序都有一些与该结构类似的地方, 但在后面使用 MFC 编写的 Windows 程序中, 第 11 章 Windows 编程的概念 521 该结构可能隐藏起来了。每种情形对应一个特定的消息 ID 值,并对该消息进行适当的处理。程序 不想单独处理的任何消息都由 default 语句处理,默认情形应该调用 DefWindowProc()函数,将消息 回传给 Windows。DefWindowProc()是提供默认消息处理机制的 Windows API 函数。 在复杂的、逐一处理许多可能的 Windows 消息的程序中,该 switch 语句可能变得很大、相当麻 烦。当使用 Application Wizard 来生成 Windows 应用程序时,将不必再担心这一点,因为向导将负 责处理这一切,我们将永远也看不到 WindowProc()函数,只需要提供代码来处理感兴趣的特定消 息即可。 绘制窗口工作区 Windows 给程序发送 WM_PAINT 消息,告诉程序应该重画应用程序的工作区。因此,在示例 中需要绘制窗口的工作区来响应 WM_PAINT 消息。 不能杂乱无章地在窗口中涂鸦。在可以向应用程序窗口写入内容之前,需要告诉 Windows 我们 想这样做,还需要得到 Windows 的授权才能继续。为此,调用 Windows API 函数 BeginPaint(),只 应该在响应 WM_PAINT 消息时才调用该函数,使用方法如下: HDC hDC; // A display context handle PAINTSTRUCT PaintSt; // Structure defining area to be redrawn hDC = BeginPaint(hWnd, &PaintSt); // Prepare to draw in the window HDC 类型表示显示设备上下文的句柄,更通常的叫法是设备上下文。设备上下文在与设备无关 的 Windows API 函数(向屏幕或打印机输出信息 )和设备驱动程序 (支持向连接到 PC 的具体设备输出 信息)之间提供链接。也可以把设备上下文看作 Windows 应我们的请求传递给我们的权限标记,它 授予我们输出某种信息的权限。如果没有设备上下文,就不能生成任何输出。 BeginPaint()函数返回设备上下文的句柄,该函数要求提供两个实参。传递的第一个实参 hWnd 是窗口句柄, 用来标识输出的目标窗口。 第二个实参是 PAINTSTRUCT 变量 PaintSt 的地址, Windows 把为了响应 WM_PAINT 消息而需要重画的区域的相关信息放在 PaintSt 结构内。本书将不讨论该结 构的细节,因为我们不打算再次使用它。此处将只是重画整个工作区。使用下面这条语句,可以在 RECT 结构中获得工作区的坐标: RECT aRect; // A working rectangle GetClientRect(hWnd, &aRect); GetClientRect()函数为第一个实参指定的窗口提供其工作区的左上角和右下角坐标。 这两个坐标 存储在第二个指针实参传递的 RECT 结构 aRect 中。使用 aRect 可以标识工作区中的一个区域, DrawText()函数在这个区域中输出文本。因为窗口是灰色的背景,所以应该将文本的背景色更改为 透明,以便让灰色显露出来;否则,文本将在白色背景上出现。可以用下面的 API 函数调用来做这 件事: SetBkMode(hDC, TRANSPARENT); // Set text background mode 第一个实参标识设备上下文,第二个实参设定背景模式。默认选项是 OPAQUE。 现在可以使用下面这条语句来输出文本: DrawText(hDC, // Device context handle Visual C++ 2012 入门经典(第 6 版) 522 L"But, soft! What light through yonder window breaks?", -1, // Indicate null terminated string & aRect, // Rectangle in which text is to be drawn DT_SINGLELINE| // Text format - single line DT_CENTER| // - centered in the line DT_VCENTER // - line centered in aRect ); DrawText()函数的第一个实参是允许在窗口上绘图的权限证书,显示设备上下文 hDC。第二个 实参是希望输出的文本字符串。也可以将该文本字符串定义在某个变量中,然后传递指向该文本字 符串的指针作为该函数调用的第二个实参。 下一个值为– 1 的实参表示该字符串是以空字符终止的。 如果不是这样, 就应当将字符串中字符的个数写在这里。 第 4 个实参是指向某个 RECT 结构的指针, 该结构定义了一个希望在其中输出文本的矩形。在本例中,该矩形是 aRect 中定义的整个窗口工作 区。最后一个实参定义了矩形中文本的格式。在这里使用按位或运算符 | 组合 3 个格式说明常量。 该字符串写在一行内,文本在这一行上居中显示,在垂直方向上,该文本行位于矩形的中心。这样 的格式处理可以将文本美观地放在窗口中心。还有许多其他选项,其中包括将文本放在矩形的顶部 或底部,以及使文本左对齐或右对齐的选项。 在输出所有希望显示的内容之后, 必须告诉Windows工作区的绘制已经结束。 对每个BeginPaint() 函数调用来说,都必须有一个对应的 EndPaint()函数调用。因此,为了结束对 WM_PAINT 消息的处 理,需要下面这条语句: EndPaint(hWnd, &PaintSt); // Terminate window redraw operation hWnd 实参标识程序窗口, 第二个实参是由 BeginPaint()函数填充的 PAINTSTRUCT 结构的地址。 3. 结束程序 有人可能认为,关闭窗口就会关闭应用程序;但为了获得这样的特性,实际上必须添加一些代 码。关闭窗口时应用程序不会自动关闭的原因在于可能需要做一些清理工作,应用程序也可能有多 个窗口。当用户通过双击标题栏图标或单击关闭按钮关闭窗口时,系统将生成一条 WM_DESTROY 消息。因此,为了关闭应用程序,需要在 WindowProc()函数中处理 WM_DESTROY 消息。可以使 用下面这条语句生成一条 WM_QUIT 消息进行处理: PostQuitMessage(0); 这里的实参是一个退出代码。顾名思义,该 Windows API 函数在应用程序的消息队列中添加一 条 WM_QUIT 消息。该消息导致 WinMain()中的 GetMessage()函数返回 FALSE,并结束消息循环, 从而终止程序。 4. 完整的 WindowProc()函数 我们已经讨论了构成本示例中完整的 WindowProc()函数所需的所有元素。该函数的代码如下 所示: // Listing OFWIN_2 LRESULT CALLBACK WindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) 第 11 章 Windows 编程的概念 523 { switch(message) // Process selected messages { case WM_PAINT: // Message is to redraw the window HDC hDC; // Display context handle PAINTSTRUCT PaintSt; // Structure defining area to be drawn RECT aRect; // A working rectangle hDC = BeginPaint(hWnd, &PaintSt); // Prepare to draw the window // Get upper left and lower right of client area GetClientRect(hWnd, &aRect); SetBkMode(hDC, TRANSPARENT); // Set text background mode // Now draw the text in the window client area DrawText( hDC, // Device context handle _T("But, soft! What light through yonder window breaks?"), -1, // Indicate null terminated string &aRect, // Rectangle in which text is to be drawn DT_SINGLELINE| // Text format - single line DT_CENTER| // - centered in the line DT_VCENTER); // - line centered in aRect EndPaint(hWnd, &PaintSt); // Terminate window redraw operation return 0; case WM_DESTROY: // Window is being destroyed PostQuitMessage(0); return 0; } return DefWindowProc(hWnd, message, wParam, lParam); } 示例说明 除了最后一条语句外,整个函数体只是一条 switch 语句而已。特定的 case 是基于 message 参数 传递给该函数的消息 ID 而选择的。 由于本示例相当简单, 只需要处理两种不同的消息: WM_PAINT 和 WM_DESTROY。在 switch 语句的后面,通过调用 DefWindowProc()函数,将所有其他消息回传 给 Windows。DefWindowProc()函数的实参就是传递给本函数的实参, 因此只需要按照原样将它们传 回去即可。注意每种消息类型处理代码最后的 return 语句。就处理的这两种消息而言,返回值是 0。 试一试:简单的 Windows API 程序 因为已经编写了 WinMain()函数和处理消息的 WindowProc()函数,所以现在完全可以创建仅使 用 Windows API 的 Windows 程序的完整源文件。当然,需要为该程序创建一个项目,但不是像迄今 一直所做的那样选择 Win32 控制台应用程序,而应该使用 Win32 项目模板创建该项目。应该选择将 其创建成一个空项目,然后添加容纳代码的 Ex11_01.cpp 文件。 // Ex11_01.cpp Native windows program to display text in a window #include LRESULT CALLBACK WindowProc(HWND hWnd, UINT message, Visual C++ 2012 入门经典(第 6 版) 524 WPARAM wParam, LPARAM lParam); // Insert code for WinMain() here (Listing OFWIN_1) // Insert code for WindowProc() here (Listing OFWIN_2) 如果编译并执行该示例,则得到如图 11-4 所示的窗口。 注意,该窗口有许多不需要通过编程来管 理的、由操作系统提供的属性。该窗口的边框 可以拖动以改变窗口大小,整个窗口也可以在 屏幕上四处移动。最大化和最小化按钮也能工 作。当然,所有这些动作都会对该程序产生影 响。每当我们改变窗口的位置或大小时,就有 一条 WM_PAINT 消息进入消息队列,程序就必 须重画工作区,但所有绘制和改变窗口本身的 工作都是由 Windows 完成的。 由于在 WindowClass 结构中指定的选项,因此系统菜单和关闭按钮也是本窗口的标准功能,管 理它们的工作仍然是由 Windows 负责的。唯一由此产生的对程序的其他作用是关闭窗口时 WM_DESTROY 消息的传递—— 在前面已讨论过这一点。 11.3 MFC MFC(Microsoft Foundation Classes,Microsoft 基本类 )是一组预定义的类,使用 Visual C++进行 Windows 编程以此作为基础。这些类封装了 Windows API,对 Windows 编程来说是一种面向对象的 方法。 MFC 没有严格遵守面向对象的封装和数据隐藏原则,主要原因是许多 MFC 代码是在这些原 则完善之前编写的。 编写 Windows 程序的过程涉及创建和使用 MFC 对象或者 MFC 的派生类对象。大体上,将根 据 MFC 派生自己的类, Visual C++ 2010 中的专用工具可以给我们提供相当多的帮助,从而使派生 过程相当简单。这些基于 MFC 的类的对象包括与 Windows 通信的成员函数、处理 Windows 消息的 成员函数以及相互发送消息的成员函数。当然,这些派生类将继承基类的所有成员。这些继承的函 数几乎要做所有使 Windows 程序工作所必需的普通工作。 我们只需要添加数据和函数成员来定制这 些类,以提供在程序中需要的专用功能。在此过程中,将应用大部分已经在前面的章节中掌握的技 术,特别是那些涉及类继承和虚函数的技术。 11.3.1 MFC 表示法 所有 MFC 类的名称都以 C 开始, 如 CDocument 或 CView。如果在定义自己的类或者根据 MFC 库的基类派生新类的时候使用相同的约定,那么程序将更易于理解。 MFC 类的数据成员以 m_作为 前缀。本书在使用 MFC 的示例中也将遵守这项约定。 我们稍后会发现, MFC 为许多变量名使用匈牙利表示法, 特别是那些源于 Windows API 的变量。 我们应该记得,匈牙利表示法使用前缀 p 表示指针,使用 n 表示 unsigned int 类型,l 表示 long,h 表示句柄等。例如,名称 m_lpCmdLine 是某个类的数据成员 (因为有 m_前缀),属于“指向字符串 图 11-4 第 11 章 Windows 编程的概念 525 的 long 型指针”类型。因为 C++具有强类型检查功能,可以挑出过去在 C 中经常发生的误用情形, 所以这种表示法不是必需的,本书后面的示例中一般不会使用该表示法来命名变量。但是将继续使 用前缀 p 来表示指针, 还将使用其他一些简单的类型指示符号, 因为这样有助于使代码更易于理解。 11.3.2 MFC 程序的组织方式 我们从第 1 章知道,在一行代码也不编写的情况下,使用 Application Wizard 就可以生成一个 Windows 程序。当然,该过程使用了 MFC 库。但不使用 Application Wizard 也完全可以编写出使用 MFC 的 Windows 程序。如果首先不求甚解地构造出最简单的基于 MFC 的程序,那么将对所涉及的 基本元素有更清楚的认识。 使用 MFC 可以生成的最简单的程序,比本章前面使用原始 Windows API 编写的示例稍微简单 一些。这次编写的示例将拥有一个窗口,但不在窗口内显示文本。这足以说明基本的组成部分,因 此让我们试一试。 试一试:最简单的 MFC 应用程序 像以前多次做过的那样, 使用 File | New | Project 菜单项创建一个新项目。 这次不使用 Application Wizard 来创建基本的代码,因此选择 Win32 Project 作为该项目的模板,并在第二个对话框中选择 Windows Application 和 Empty project 选项。在创建该项目之后,从主菜单中选择 Project | Ex11_02 properties,并在 Configuration Properties 的 General 子页上单击 Use of MFC 属性,将其属性值设置为 Use MFC in a Shared DLL。 在这个已创建的项目中,可以创建一个新的源文件 Ex11_02.cpp。为了在一个地方看到该程序 的所有代码,可以将需要的类定义及类的实现都放在该文件中。为此,只需要在编辑窗口中手动添 加代码即可—— 所需的代码不是很多。 首先,要添加一条包括 afxwin.h 头文件的 #include 语句,因为该文件包含许多 MFC 类的定义。 这样,就可以从 MFC 中派生自己的类。 #include // For the class library 要得到完整的程序,只需要从 MFC 中派生两个类即可:应用程序类和窗口类。我们甚至不需要 像在本章前一个示例中那样编写 WinMain()函数,因为该函数是由 MFC 库在后台自动提供的。看一 看如何定义需要的这两个类。 1. 应用程序类 CWinApp 类对任何使用 MFC 编写的 Windows 程序来说都很重要。该类的对象包括启动、初始 化、运行和关闭应用程序所需的一切代码。需要根据 CWinApp 派生自己的应用程序类,从而得到 自己的应用程序。我们将定义该类的专用版本以适应特定的应用需求。该派生类的代码如下所示: class COurApp: public CWinApp { public: virtual BOOL InitInstance(); }; Visual C++ 2012 入门经典(第 6 版) 526 因为要实现一个简单的示例, 所以这里不需要太多的特殊化工作。 该类的定义中只有一个成员: InitInstance()函数。在基类中将该函数定义为虚函数,因此在派生类中它是一个重写函数;我们只是 在为自己的应用程序类重新定义这个基类函数而已。该类中所有其他从 CWinApp 类继承的数据和 函数成员都保持不变。 该应用程序类具有大量在基类中定义的数据成员,其中许多都对应于用作 Windows API 函数实 参的变量。例如,成员 m_pszAppName 存储着指向定义应用程序名的字符串的指针。成员 m_nCmdShow 指定应用程序启动时以什么方式显示应用程序窗口。 现在不必考虑所有继承的数据成 员。在开发应用程序专用代码的过程中,将在需要使用这些成员时了解它们的用法。 在根据 CWinApp 派生自己的应用程序类时,必须重写虚函数 InitInstance()。该函数的重写版本 是由 MFC 为我们提供的 WinMain()函数调用的, 我们将在该函数中包括创建和显示应用程序窗口的 代码。但在编写 InitInstance()之前,应该先了解一下 MFC 库中定义窗口的类。 2. 窗口类 MFC 应用程序需要一个窗口作为与用户交互的界面,称为框架窗口。需要为应用程序从 MFC 类 CFrameWnd 中派生一个窗口类, CFrameWnd 类是专门为上述目的而设计的。因为 CFrameWnd 类提供了创建和管理应用程序窗口所需的一切,所以我们只需要给派生窗口类添加一个构造函数。 构造函数允许指定窗口的标题栏,以适应应用程序的上下文: class COurWnd: public CFrameWnd { public: // Constructor COurWnd() { Create(NULL, _T("Our Dumb MFC Application")); } }; 在构造函数中调用的 Create()函数是从基类继承的。 该函数创建一个窗口, 并使该窗口附属于正 被创建的 COurWnd 对象。注意, COurWnd 对象与 Windows 显示的窗口不是一回事— — 类对象与物 理窗口是截然不同的实体。 Create()函数的第一个实参值是 NULL,表明我们希望为创建的窗口使用基类的默认属性。还记 得吗?在本章前一个示例中需要直接使用 Windows API 定义窗口属性。第二个实参指定在窗口标题 栏中使用的窗口名称。 Create()函数还有其他参数,但那些参数都有完全令人满意的默认值,因此 这里可以将它们全部忽略。 3. 完成程序 为应用程序定义过窗口类之后,就可以编写 COurApp 类中的 InitInstance()函数: BOOL COurApp::InitInstance(void) { m_pMainWnd = new COurWnd; // Construct a window object... m_pMainWnd->ShowWindow(m_nCmdShow); // ...and display it return TRUE; } 第 11 章 Windows 编程的概念 527 如前所述, 该函数重写了基类 CWinApp中定义的虚函数, 虚函数由 MFC库自动提供的WinMain() 函数调用。 InitInstance()函数通过使用 new 操作符在空闲存储器中为应用程序构造了一个主窗口对 象。将返回的地址存入 m_pMainWnd 变量中,该变量是 COurApp 类中从基类继承的成员。这么 做的结果是使该窗口对象附属于应用程序对象。我们甚至不需要考虑为创建的对象释放内存的问 题—— 任何必要的清理工作都由 MFC 提供的 WinMain()函数负责。 对于虽然相当有限、但也是完整的程序而言,还需要定义应用程序对象。在执行 WinMain()之 前,应用程序类 COurApp 的某个实例必须存在,因此必须使用下面这条语句在全局作用域声明该实例: COurApp AnApplication; // Define an application object 该对象之所以需要存在于全局作用域,是因为它是应用程序,而应用程序需要在开始执行之前 存在。 MFC 提供的 WinMain()函数要调用应用程序对象的 InitInstance()函数成员来构造窗口对象, 这表示应用程序对象是存在的。 4. 最终产品 既然我们已经看过所有代码,现在就可以在该项目的 Ex11_02.cpp 源文件中添加它们。在 Windows 程序中,类通常是在 .h 文件中定义的,而不在类内部定义的成员函数是在 .cpp 文件中定义 的。但我们的应用程序是如此之短,因此最好把所有代码都放入一个 .cpp 文件中。这么做的优点是 可以同时查看全部代码。该程序的代码如下所示: // Ex11_02.cpp An elementary MFC program #include // For the class library // Application class definition class COurApp:public CWinApp { public: virtual BOOL InitInstance() override; }; // Window class definition class COurWnd:public CFrameWnd { public: // Constructor COurWnd() { Create(NULL, _T("Our Dumb MFC Application")); } }; // Function to create an instance of the main application window BOOL COurApp::InitInstance(void) { m_pMainWnd = new COurWnd; // Construct a window object... m_pMainWnd->ShowWindow(m_nCmdShow); // ...and display it return TRUE; } // Application object definition at global scope Visual C++ 2012 入门经典(第 6 版) 528 COurApp AnApplication; // Define an application object 这就是我们需要执行的全部操作。该程序看起来有点儿奇特,因为没有 WinMain()函数;但正 如上面提到的那样,事实上有一个由 MFC 库提供的 WinMain()函数。 示例说明 我们现在即将大功告成, 接下来构建并运行该应用程序。 选择 Build | Build Ex11_02.exe 菜单项, 单击适当的工具栏按钮,或者仅仅按下 Ctrl+Shift+B 组合键,都可以编译该解决方案。最后应该进 行完全的编译和链接,这样就可以按下 Ctrl+F5 组合键运行该程序。这个最简单的 MFC 程序如图 11-5 所示。 可以拖动边框调整该窗口的大小,可以四处移动整个窗口,或者以通常的方式将其最小化或最 大化。该程序支持的其他功能只有“关闭” ;可以使用系统菜单,使用窗口右上角的关闭按钮,或者 只是按下 Alt+F4 组合键来关闭程序。该程序看起来好像没有多少东西,但考虑到只有很少的几行代 码,它还是给我们留下了十分深刻的印象。 图 11-5 11.4 小结 本章学习了两种使用 Visual C++创建简单的 Windows 应用程序的方法,现在应该对这两种方法 之间的关系具有一定程度的感性认识。在本书剩余的章节中,我们将更深入地探讨如何使用 MFC 开发应用程序。 11.5 本章主要内容 本章主要内容如表 11-5 所示。 表 11-5 主 题 概 念 Windows API Windows API 提供了标准的编程接口,应用程序通过该接口与 Windows 操作系统通信 Windows 消息 Windows 把消息传递给桌面应用程序,从而与之通信。消息一般是指发生了某种事件,需要应 用程序做出响应 第 11 章 Windows 编程的概念 529 (续表) 主 题 概 念 Windows 应用程序的 结构 所有 Windows 桌面应用程序都必须包含两个函数 WinMain()和 WindowProc(),它们由操作系统 调用。WindowProc()函数的名称可以任意 WinMain()函数 WinMain()函数由操作系统调用,启动应用程序的执行过程。 WinMain()函数中的代码还可以设 定应用程序所需的初始条件、指定应用程序窗口、检索操作系统中用于应用程序的消息 WindowProc()函数 Windows 操作系统调用一个特殊的函数,它通常称为 WindowProc(),来处理消息。桌面应用程 序通过把一个指向消息处理函数的指针传递给某个 Windows API 函数(作为 WNDCLASSX 结 构的一部分),来标识应用程序中各个窗口的消息处理函数 MFC MFC 由一组封装了 Windows API 的类组成,极大地简化了 Windows 桌面应用程序的编写 在窗口中绘图 本章要点 ● Windows 为窗口绘图提供的坐标系统 ● 如何使用设备上下文提供的功能来绘制形状 ● 程序如何以及何时在窗口中绘图 ● 如何定义鼠标消息的处理程序 ● 如何定义自己的形状类 ● 在窗口中绘制形状时如何对鼠标进行编程 ● 如何让程序捕获鼠标 14.1 窗口绘图的基础知识 如果要在窗口工作区中绘图,则必须遵守某些规则。每当将 WM_PAINT 消息发送到应用程序 时,就必须重画工作区。这是因为有许多外部事件需要应用程序重新绘制这个窗口—— 如用户调整 了正在绘图的窗口大小,或者是在用户移动另一个窗口以暴露以前隐藏的窗口。 Windows 操作系统 将一些信息与 WM_PAINT 消息一起发送,以便确定哪部分工作区需要重新创建。这就意味着在响 应每个 WM_PAINT 消息时不必绘制所有工作区,而只需要绘制标识为更新区的区域。在 MFC 应用 程序中,MFC 解释 WM_PAINT 消息,并将它重定向到某一个类中的一个函数。本章稍后将解释如 何处理这一消息。 14.1.1 窗口工作区 由于可以使用鼠标来回拖动窗口,并且可以通过拖动其边框调整窗口大小,因此窗口在屏幕上 没有一个固定的位置, 甚至没有一个固定的可视区。 那么如何知道应当在屏幕上的什么地方绘图呢? 幸运的是不需要了解这个问题。因为 Windows 提供了一种一致的窗口绘图方法,不必担心图形 在屏幕上的位置,否则窗口绘图将变得非常复杂。 Windows 为窗口的工作区提供了一种坐标系统, 14 第 章 Visual C++ 2012 入门经典(第 6 版) 574 它对于这个窗口来说是本地的。它始终把工作区的左上角作为它的参考点。工作区中的所有点都是 相对于这个点定义的,如图 14-1 所示。 原点(0,0) x 轴正方向 y 轴正方向 点(a,b) MM_TEXT 映射模式坐标 图 14-1 不管这个窗口在屏幕上的什么地方,也不管它有多大,某个点相对于工作区左上角的水平距离 和垂直距离始终不变。当然, Windows 需要跟踪这个窗口在什么地方,在工作区中的一个点上进行 绘图操作时,Windows 需要查明这个点在屏幕上的实际位置。 14.1.2 Windows 图形设备界面 实际上并没有把数据写到屏幕上。所有到显示屏的输出都是图形,而不管它是直线、圆还是文 本。Windows 坚持使用图形设备界面 (Graphical Device Interface,GDI)定义这种输出。 GDI 支持在对 图形输出编程时不依赖于显示它的硬件,这意味着程序不进行任何修改,就可以在具有不同显示硬 件的不同机器上运行。 GDI 还支持打印机和绘图仪,所以,将数据输出到打印机和绘图仪时涉及的 机制实际上与在屏幕上显示信息时一样。 1. 使用设备上下文 在图形输出设备(如显示屏)上进行绘图操作时,必须使用设备上下文。设备上下文是一种 Windows 数据结构, 它包含的信息允许 Windows 将输出请求转换成对正在使用的特定物理输出设备 的动作。输出请求采用与设备无关的 GDI 函数调用形式。 MFC 类 CDC 封装了一个设备上下文,所 以对该类型的对象调用函数,就可以执行所有的绘图操作。把一个指向 CDC 对象的指针提供给视 图类对象的 OnDraw()成员函数,就可以在该视图表示的客户区中绘图。要将输出发送到其他图形设 备时,也使用设备上下文。 设备上下文提供了一种称为映射模式的可选坐标系统,它将被自动转换成客户区坐标。通过调 用 CDC 对象的函数,还可以更改许多影响到设备环境的输出的参数,这样的参数称为属性。可以 更改的属性有绘图颜色、背景色、绘图使用的线宽以及文本输出的字体等。 2. 映射模式 设备上下文中的每种映射模式都由一个 ID 标识,其方式与标识 Windows 消息类似。每个 ID 都 第 14 章 在窗口中绘图 575 有前缀 MM_,表明它定义了映射模式。Windows 提供的映射模式如表 14-1 所示。 表 14-1 映 射 模 式 说 明 MM_TEXT 逻辑单位是一个设备像素,在窗口工作区中,x 轴的正方向从左到右,y 轴的正方向从上 到下 MM_LOENGLISH 逻辑单位是 0.01 英寸,在工作区中,x 轴的正方向从左到右,y 轴的正方向从上到下 MM_HIENGLISH 逻辑单位是 0.001 英寸,x 轴和 y 轴的方向与 MM_LOENGLISH 相同 MM_LOMETRIC 逻辑单位是 0.1 毫米,x 轴和 y 轴的方向与 MM_LOENGLISH 相同 MM_HIMETRIC 逻辑单位是 0.01 毫米,x 轴和 y 轴的方向与 MM_LOENGLISH 相同 MM_ISOTROPIC 逻辑单位是任意长度,但是在 x 轴和 y 轴上是相同的。 x 轴和 y 轴的方向与 MM_ LOENGLISH 相同 MM_ANISOTROPIC 这种模式类似于 MM_ISOTROPIC,但是它允许 x 轴上逻辑单位的长度不同于 y 轴上逻辑 单位的长度 MM_TWIPS 逻辑单位是 TWIP,其中 TWIP 是一个点的 0.05,而一个点是 1/72 英寸。所以 TWIP 相当 于 1/1440 英寸,即 6.9×10-4 英寸。 (点是衡量字体的单位 )。x 轴和 y 轴的方向与 MM_LOENGLISH 相同 本书不打算使用所有这些映射模式。但是,本书将使用那些可用模式的良好典型,所以需要使 用其他映射模式时,将不会遇到任何问题。 MM_TEXT 是设备上下文的默认映射模式。如果需要使用一种不同的映射模式,就必须设法修 改它。需要注意的是,在 MM_TEXT 模式中, y 轴的正方向与您在高中学习坐标几何时相反,如图 14-1 所示。 默认情况下,在每种映射模式中,位于工作区左上角的点的坐标都是 (0,0),但是也可以把原点 移动到其他位置。例如,以图形形式显示数据的应用程序把原点移动到工作区的中心,将更容易绘 制数据。还可以相对于 (0,0)来定义形状,再将原点移动到要绘制的形状的位置。这意味着形状无论 在什么地方,都可以用相同的代码绘制。 当原点在 MM_TEXT 模式中位于左上角时,距左边框 50 个像素、距工作区顶部 100 个像素的 点的坐标是 (50,100)。当然,因为单位是像素,屏幕上这个点与工作区左上角的距离就取决于显示器 的分辨率。如果把显示器的分辨率设置为 1280×1024,那么与设置为 1024×768 的分辨率相比,这 个点将离工作区左上角比较近,因为像素比较小。对于在这种映射模式中绘制的一个对象来说,它 在分辨率为 1280×1024 时的尺寸要比分辨率为 1024×768 时的尺寸小。注意,在所有映射模式中, 显示器的 DPI 设置都将影响显示。默认设置采用 96 DPI,所以,如果把显示器的 DPI 设置成另外一 个值,那么这将影响图形的外观。坐标始终是 32 位有符号整数,整个图的最大物理尺寸因坐标单位 的物理长度而异,坐标单位的物理长度是由映射模式确定的。 在除 MM_TEXT 之外的其余所有映射模式中,x 轴和 y 轴的方向都一样,但是它们与 MM_TEXT 模式中的不一样。MM_TEXT 模式中的 y 轴的方向相反。MM_LOENGLISH 模式的坐标轴如图 14-2 所示。虽然 y 轴的正方向与您在高中时学习的一致 (在屏幕上向上移动时 y 值增加 ),但是 MM_LOENGLISH 模式仍然有点古怪,因为其中的原点位于工作区的左上角,所以对于可视工作区内 的点来说,它们的 y 值始终是负数。 Visual C++ 2012 入门经典(第 6 版) 576 原点(0,0) 点(a,–b) x 轴正方向 y 轴负方向 MM_LOENGLISH 映射模式坐标 图 14-2 在 MM_LOENGLISH 映射模式中, x 轴和 y 轴上的单位都是 0.01 英寸,所以位置 (50,-100)处的 点到左边框的距离是半英寸,到工作区顶部的距离是 1 英寸。无论显示器的分辨率是多少,对象在 工作区中的大小都一样。 如果在 MM_LOENGLISH 模式中画了一个 x值为负数或 y值为正数的对象, 那么它将位于工作区之外,因而看不见。调用 CDC 类(该类封装了设备上下文,稍后讨论 )的 SetViewportOrg()成员,可以移动原点的位置。 14.2 MFC 的绘图机制 MFC 将 Windows 界面封装到屏幕和打印机中,所以在对图形输出编程时不必担心很多有关的 细节。如第 13 章所述, Application Wizard 生成的程序已经包含了一个派生于 MFC 类 CView 的类, 它专门设计用于在屏幕上显示文档数据。 14.2.1 应用程序中的视图类 MFC Application Wizard 生成的类 CSketcherView 将在文档窗口的工作区中显示文档的信息。类 定义包括几个虚函数的重写,不过在此处着重介绍的一个函数是 OnDraw()。每当需要重新绘制文档 窗口的工作区时,都将调用这个函数。当程序接收到 WM_PAINT 消息时,应用程序框架调用的正 是这个函数。 OnDraw()成员函数 由 MFC Application Wizard 创建的 OnDraw()成员函数的实现如下所示: void CSketcherView::OnDraw(CDC* /*pDC*/) { CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; // TODO: add draw code for native data here } 第 14 章 在窗口中绘图 577 一个指向 CDC 类对象的指针被传递到视图类的 OnDraw()成员函数。这个对象包含的成员函数 将调用 Windows API 函数,这些函数允许在设备上下文中绘图。参数名以注释的形式存在,所以在 使用这个指针之前,必须解除这个名称的注释,或者用自己的名称代替这个名称。 因为将使用所有这些代码在 OnDraw()成员函数中绘制文档,所以 Application Wizard 已经包括 了指针 pDoc 的声明,并且使用函数 GetDocument()对这个指针进行了初始化,函数 GetDocument() 将返回与当前视图有关的文档对象的地址: CSketcherDoc* pDoc = GetDocument(); 函数 GetDocument()定义在 CSketcherView 类中,它将从 m_pDocument 检索指向文档的指针, m_pDocument 是视图对象的继承的数据成员。这个函数将执行重要的任务,就是把存储在这个数据 成员中的指针强制转换成对应于应用程序中文档类 CSketcherDoc 的类型。这样,编译器就可以使用 此指针访问已经定义的文档类的成员; 否则, 编译器只能够使用此指针访问基类的成员。 因此, pDoc 将指向应用程序中与当前视图相关联的文档对象,下面要使用这个指针访问存储在文档中的数据。 下面这一行代码: ASSERT_VALID(pDoc); 这个宏确保指针 pDoc 包含有效的地址, 后面的 if 语句确保 pDoc 不是空的。 在应用程序的发布 版本中,忽略 ASSERT_VALID。 在 OnDraw()函数中,参数 pDC 的名称代表“指向设备上下文的指针” 。将参数 pDC 所指向的 CDC 类的对象是在窗口中绘图的关键。 CDC 类为视图的工作区提供设备上下文,并且提供将图形 和文本写入它时需要的工具,所以需要详细地讨论它。 14.2.2 CDC 类 应当使用 CDC 类的成员在程序中完成所有绘图。这个类和其派生的类的所有对象都包含把图 形和文本发送到显示器和打印机时需要的一个设备上下文和成员函数。另外还有一些成员函数用于 检索有关正在使用的物理输出设备的信息。 由于 CDC 类对象可以通过图形输出提供用户可能需要的几乎所有东西,所以这个类的成员函 数很多— —实际上大大超过了 100 个。所以,本章只分析打算在 Sketcher 程序中使用的成员函数, 以后需要使用其他成员函数时再分析它们。 对于图形输出, MFC 包括一些派生于 CDC 的更专用的类。例如,我们要使用 CClientDC 类的 对象。 CClientDC 类超过 CDC 类的优点是它始终包含只代表窗口工作区的设备上下文,这正是用户 在大多数情况下所需要的。 1. 显示图形 在设备上下文中,将相对于当前位置绘制实体,如直线、圆和文本。当前位置是工作区中的一 个点,它或者由以前绘制的实体设置,或者是通过调用函数进行设置。例如,可以扩展 OnDraw() 函数,设置如下所示的当前位置: void CSketcherView::OnDraw(CDC* pDC) { CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); Visual C++ 2012 入门经典(第 6 版) 578 if(!pDoc) return; pDC->MoveTo(50, 50); // Set the current position as 50,50 } 第一个行是粗体显示, 因为编译代码时, 必须解除参数名注释。 第二个粗体显示的行将调用 pDC 所指的 CDC 对象的 MoveTo()函数。这个成员函数将当前位置设置为参数指定的 x 和 y 坐标。默认 映射模式是 MM_TEXT,所以坐标的单位是像素,当前位置设置成一个距窗口内部左边框 50 个像 素、距工作区顶部 50 个像素的点。 CDC 类将重载 MoveTo()函数,这样就可以灵活地指定设置当前位置的方式。 MoveTo()函数有 两个版本,它们在 CDC 类中声明为: CPoint MoveTo(int x, int y); // Move to position x,y CPoint MoveTo(POINT aPoint); // Move to position defined by aPoint 第一个版本接受作为独立参数的 x 和 y 坐标。 第二个版本接受一个 POINT 类型的参数, 它是一 个具有如下定义的结构: typedef struct tagPOINT { LONG x; LONG y; } POINT; 其中的坐标是 struct 的 x 和 y 成员,类型为 LONG(这种类型在 Windows API 中定义为 32 位有 符号整数)。您可能喜欢使用类,而不喜欢使用结构,这时可以在能够使用 POINT 对象的地方使用 CPoint 类的对象。CPoint 类具有 LONG 类型的数据成员 x 和 y,使用 CPoint 对象的优点在于这个类 还定义了操作 CPoint 和 POINT 对象的成员函数。这看来似乎不可思议,因为 CPoint 的出现似乎使 POINT 对象变得过时,但是要记住, Windows API 是在 MFC 出现之前建立的,而且 POINT 对象是 在 Windows API 中使用的,所以迟早要处理它。由于在示例中要使用 CPoint 对象,因此用户将有机 会了解其中一些成员函数的应用。 MoveTo()函数的返回值是一个 CPoint 对象,它指定的当前位置和移动之前一样。这也许有点奇 怪,不过考虑这样一种情况:您想移动到一个新位置,画点东西,然后退回来。在移动之前,您也 许不知道当前位置,在移动以后,将丢失当前位置,所以在移动之前返回这个位置将确保您在需要 时可以使用它。 绘制直线 在对 OnDraw()函数中的 MoveTo()调用以后,调用函数 LineTo(),这将在工作区中绘制一条直线, 它从当前位置到 LineTo()函数的参数指定的位置,如图 14-3 所示。 图 14-3 第 14 章 在窗口中绘图 579 CDC 类还定义两个版本的 LineTo()函数,它们具有下列原型: BOOL LineTo(int x, int y); // Draw a line to position x,y BOOL LineTo(POINT aPoint); // Draw a line to position defined by aPoint 和 MoveTo()函数一样,在指定 LineTo()函数的参数时,这些版本具有同样的灵活性。可以把 CPoint 对象作为该函数第二个版本的参数。如果画出了这条直线,那么这个函数返回 TRUE,否则 返回 FALSE。 在执行 LineTo()函数时,当前位置将变换到这条直线末端指定的点。这样就可以绘制一系列连 线,绘制每条直线时只需要调用 LineTo()函数。观察下列版本的 OnDraw()函数: void CSketcherView::OnDraw(CDC* pDC) { CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; pDC->MoveTo(50,50); // Set the current position pDC->LineTo(50,200); // Draw a vertical line down 150 units pDC->LineTo(150,200); // Draw a horizontal line right 100 units pDC->LineTo(150,50); // Draw a vertical line up 150 units pDC->LineTo(50,50); // Draw a horizontal line left 100 units } 把这段代码插入 Sketcher 程序,然后执行,将显示如图 14-4 所示的文档窗口。不要忘记解除参 数名的注释。 图 14-4 对 LineTo()函数的 4 次调用从左上角开始按逆时针方向绘制出这个矩形。第一次调用使用的是 由 MoveTo()函数指定的当前位置;随后的调用使用前一个 LineTo()函数调用设置的当前位置。可以 使用这种方法绘制任何由一系列头尾相连的直线组成的图形。当然,可以随时使用 MoveTo()函数改 变当前位置。 绘制圆 在绘制圆时, CDC 类中有几种函数成员可供选择,不过它们全都是设计用于绘制椭圆的。由高 中几何可知,圆是椭圆的一种特例,是长轴等于短轴的椭圆,因此可以使用成员函数 Ellipse()绘制 圆。和 CDC 类支持的其他闭合形状一样, Ellipse()函数将利用设置的颜色填充形状的内部。内部颜 Visual C++ 2012 入门经典(第 6 版) 580 色由选入设备上下文的画笔确定。 画笔是一个 GDI 对象,用于在窗口中绘图, 封装在 MFC 类 CBrush 中。设备上下文中的当前画笔确定如何填充闭合形状。Ellipse()函数有两个版本: BOOL Ellipse(int x1, int y1, int x2, int y2); BOOL Ellipse(LPCRECT lpRect); 第一个版本绘制一个由矩形界定的椭圆,此矩形由点 (x1,y1)和(x2,y2)定义。在第二个版本中, 椭圆由函数的参数指向的 RECT 对象来定义。函数也接受指向 MFC 类 CRect 的对象的指针,CRect 类有 4 个公有数据成员: left、top、right 和 bottom。它们分别对应于矩形左上角点和右下角点的 x 和 y 的坐标。 CRect 类也提供一系列在 CRect 对象上操作的函数成员,稍后将使用其中的一些函数 成员。如果 Ellipse()函数操作成功,则返回 TRUE,否则返回 FALSE。使用 Ellipse()函数的这两个版 本中的任一个版本,绘制的椭圆都扩展到矩形的右边和底部,但不包含它们。这就意味着椭圆的宽 度和高度分别是 x2-x1 和 y2-y1。 可以设置 CBrush 对象的颜色,也可以在填充闭合形状 (如椭圆 )时定义要产生的模式。如果想绘 制不进行填充的闭合形状,那么可以选择使用设备上下文中的空画笔,这时形状的内部将是空白。 本章稍后将讨论画笔。 绘制不进行填充的圆的另一种方法是使用 Arc()函数,它不涉及画笔。因为 Arc()函数绘制的曲 线是不闭合的,所以不能填充。 Arc()的优点是可以绘制椭圆的任意一段弧。这个函数在 CDC 类中 有两个版本,它们的声明如下所示: BOOL Arc(int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4); BOOL Arc(LPCRECT lpRect, POINT startPt, POINT endPt); 在第一个版本中, (x1,y1)和(x2,y2)定义包围整个曲线的矩形的左上角和右下角。如果把这些坐 标变成正方形的角,那么绘制的曲线就是圆的一段。点 (x3,y3)和(x4,y4)定义这段曲线的起点和终点。 这段曲线是按逆时针方向绘制的。如果使 (x3,y3)和(x4,y4)相等,那么将生成一个完整的、表面上似 乎是闭合的曲线。但实际上它并不是闭合的曲线。 在 Arc()函数的第二个版本中, 封闭矩形由 RECT 对象定义, 指向这个对象的指针将作为第一个 参数进行传递。POINT 对象 StartPt 和 EndPt 分别定义要绘制的弧的起点和终点。 下面的代码用于练习 Ellipse()和 Arc()函数: void CSketcherView::OnDraw(CDC* pDC) { CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; pDC->Ellipse(50,50,150,150); // Draw the 1st (large) circle // Defi ne the bounding rectangle for the 2nd (smaller) circle CRect rect(250,50,300,100); CPoint start(275,100); // Arc start point CPoint end(250,75); // Arc end point pDC->Arc(&rect, start, end); // Draw the second circle } 在定义边界矩形时,使用的是 CRect 类对象,而不是 RECT 结构,另外还使用了 CPoint 对象, 第 14 章 在窗口中绘图 581 而没有使用 POINT 结构。后面还要使用 CRect 对象,但是我们将会了解到,它们有一些局限性。 Arc()和 Ellipse()函数不要求设置当前位置,因为弧的位置和大小完全由提供的参数定义。当前位置 不受弧或椭圆的绘制的影响—— 它一直保持在绘制形状之前的位置。 在 OnDraw()函数中有这些代码 时,试着运行 Sketcher 程序。结果应如图 14-5 所示。 图 14-5 试着调整边界的大小。当覆盖或者露出图片中的弧时,将自动重新绘制工作区。记住,屏幕分 辨率将影响所显示图形的比例。使用的屏幕分辨率越低,弧越大,并且距离工作区左上角越远。 2. 利用颜色绘图 到目前为止,绘制的所有图形在屏幕中都是黑色的。绘图意味着要使用设置了颜色、线宽和线 型(实线、虚点线、虚线等 )的钢笔对象,钢笔是另一个 GDI 对象。我们一直在使用设备上下文中提 供的默认钢笔对象。 当然,并不强迫您这么做, 您可以创建具有给定线宽、 颜色和线型的钢笔。 MFC 定义的类 CPen 可以提供帮助。 创建钢笔 创建钢笔对象的最简单方法是首先使用默认的类构造函数定义一个 CPen 类的对象: CPen aPen; // Declare a pen object 这个对象必须用适当的属性初始化。这要调用该对象的成员函数 CreatePen(),它在 CPen 类中 声明为: BOOL CreatePen (int aPenStyle, int aWidth, COLORREF aColor); 只要成功初始化了钢笔,那么这个函数返回 TRUE,否则返回 FALSE。第一个参数定义线型。 线型必须用下列符号值之一指定,见表 14-2。 表 14-2 画 笔 线 型 说 明 PS_SOLID 绘制实线 PS_DASH 绘制虚线。只有在把钢笔宽度指定为 1 时,这种线型才有效 Visual C++ 2012 入门经典(第 6 版) 582 (续表) 画 笔 线 型 说 明 PS_DOT 绘制点线。只有在把钢笔宽度指定为 1 时,这种线型才有效 PS_DASHDOT 绘制一划一点相间的直线。只有在把钢笔宽度指定为 1 时,这种线型才有效 PS_DASHDOTDOT 绘制一划双点相间的直线。只有在把钢笔宽度指定为 1 时,这种线型才有效 PS_NULL 不进行任何绘制 PS_INSIDEFRAME 绘制实线,但是和 PS_SOLID 不同,指定实线的点出现在钢笔的边缘而不是中心,所以 绘制的对象永远不会超出包围封闭形状(例如,椭圆)的矩形 CreatePen()函数的第二个参数定义线宽。如果 aWidth 的值是 0,那么无论使用何种映射模式, 直线的宽度都是 1 像素。对 1 或以上的值,钢笔宽度的单位将由映射模式确定。例如,如果 aWidth 的值是 2,那么在 MM_TEXT 模式中,钢笔宽度是 2 像素;而在 MM_LOENGLISH 模式中,钢笔 宽度是 0.02 英寸。 最后一个参数指定钢笔的颜色,可以利用下列语句初始化钢笔: aPen.CreatePen(PS_SOLID, 2, RGB(255,0,0)); // Create a red solid pen 假定映射模式是 MM_TEXT,那么这个钢笔将绘制宽度为 2 像素的红色实线。 RGB 是第 13 章 介绍的宏,它创建了一个 24 位的颜色值,该值由 3 个无符号的整数值组成,分别表示颜色中的红、 绿、蓝成分。 也可以在构造函数中用指定的直线类型、宽度和颜色创建一个钢笔对象: CPen aPen(PS_SOLID, 2, RGB(0, 255, 0)); // Create a green solid pen 以这种方式创建自己的钢笔时,就是在创建一个 Windows GDI PEN 对象,它封装在 CPen 对象 中。删除 CPen 对象时, CPen 析构函数会自动删除 GDI 钢笔对象。如果显式创建 GDI PEN 对象, 就必须调用 DeleteObject(),并把 PEN 对象作为参数,才能删除该对象。 使用钢笔 要使用钢笔,必须把它选入设备上下文中。为此需要使用 CDC 对象的成员函数 SelectObject()。 在选择钢笔时,将用钢笔对象的地址为参数来调用这个函数。这个函数将返回一个指向先前所用钢 笔对象的指针,这样就可以把它保存起来,并在完成绘图时还原以前的钢笔。选择钢笔的典型语句 如下所示: CPen* pOldPen = pDC->SelectObject(&aPen); // Select aPen as the pen 无论将什么样的钢笔选入设备上下文中,使用完钢笔之后,必须将设备上下文恢复到它的原始 状态。要还原以前的钢笔,只需要再次调用 SelectObject()函数,以传递从最初调用返回的指针: pDC->SelectObject(pOldPen); // Restore the old pen CPen 类的另一个构造函数可以创建自己的线型。线型用一组值来指定, 这组值指 定了一划的长度和两个划之间的空间。 第 14 章 在窗口中绘图 583 如果把 CSketcherView 类中前一个版本的 OnDraw()函数修改成: void CSketcherView::OnDraw(CDC* pDC) { CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if(!pDoc) return; // Declare a pen object and initialize it as // a red solid pen drawing a line 2 pixels wide CPen aPen; aPen.CreatePen(PS_SOLID, 2, RGB(255, 0, 0)); CPen* pOldPen = pDC->SelectObject(&aPen); // Select aPen as the pen pDC->Ellipse(50,50,150,150); // Draw the 1st (large) circle // Define the bounding rectangle for the 2nd (smaller) circle CRect rect(250,50,300,100); CPoint start(275,100); // Arc start point CPoint end(250,75); // Arc end point pDC->Arc(&rect,start, end); // Draw the second circle pDC->SelectObject(pOldPen); // Restore the old pen } 就可以看到上述应用的结果。 如果利用这个版本的 OnDraw()函数构建和执行 Sketcher 应用程序, 那么将得到和以前绘制的相 同的弧,不过这次的线条比较粗,并且是红色的。针对 CreatePen()函数尝试使用不同的参数组合, 并观察它们的结果,可以有效地对这个示例进行实验。注意,因为忽略了 CreatePen()函数的返回值, 所以这个函数有可能失败,而且在程序中检测不到这种情况。不过现在这种情况并没有影响,因为 这个程序非常简单,但是在开发程序时,检查这种故障将变得非常重要。 创建画笔 CBrush 类的对象封装了 Windows 画笔。可以把画笔定义成纯色、阴影线或者有图案的画笔。 画笔实际上是一个 8×8 的像素块,它在要填充的区域上重复应用。 要定义纯色的画笔,可以在创建画笔对象时指定颜色。例如, CBrush aBrush(RGB(255,0,0)); // Define a red brush 这个语句定义了红色画笔。传递到这个构造函数的值必须是 COLORREF 类型,这是由 RGB() 宏返回的类型,所以在指定颜色时,这是一种好方法。 可以使用另一种构造函数定义阴影线画笔。这需要指定两个参数,和以前一样,第一个参数定 义阴影线的类型,第二个参数指定颜色。阴影线参数可以是下列符号常量之一,见表 14-3。 表 14-3 阴影线类型 说 明 HS_HORIZONTAL 水平阴影线 HS_VERTICAL 垂直阴影线 Visual C++ 2012 入门经典(第 6 版) 584 (续表) 阴影线类型 说 明 HS_BDIAGONAL 从左到右的 45°下行阴影线 HS_FDIAGONAL 从左到右的 45°上行阴影线 HS_CROSS 水平和垂直交叉阴影线 HS_DIAGCROSS 45°交叉阴影线 因此,要获得红色的 45°交叉阴影线画笔,可以利用下列语句定义 CBrush 对象: CBrush aBrush(HS_DIAGCROSS, RGB(255,0,0)); 在初始化CBrush对象时, 也可以使用类似于初始化 CPen对象时的方式, 对纯色画笔使用 CBrush 类的 CreateSolidBrush()成员函数,对阴影线画笔使用这个类的 CreateHatchBrush()成员函数。它们需 要的参数和对应的构造函数相同。例如,可以利用下列语句创建和前面一样的阴影线画笔: CBrush aBrush; // Define a brush object aBrush.CreateHatchBrush(HS_DIAGCROSS, RGB(255,0,0)); 使用画笔 要使用画笔,应当按照与钢笔类似的方式调用 CDC 类的 SelectObject()成员函数,把画笔选入 设备上下文中。为了把画笔对象选入设备上下文中,将重载这个成员函数。在选择以前定义的画笔 时,只需要编写下列语句: CBrush* pOldBrush = pDC->SelectObject(&aBrush); // Select the brush into the DC SelectObject()函数返回指向旧画笔的指针,如果操作失败,则返回 NULL。函数执行完成之后, 使用返回的此指针可以将旧画笔存储在设备上下文中。 有 7 种标准画笔可用。每种标准画笔都由预定义的符号常量标识。它们分别是: GRAY_BRUSH LTGRAY_BRUSH DKGRAY_BRUSH BLACK_BRUSH WHITE_BRUSH HOLLOW_BRUSH NULL_BRUSH 这些画笔名称的含义不言自明。要使用画笔,需要调用 CDC 类的 SelectStockObject()成员函数, 把想要使用的画笔的符号名称作为参数进行传递。要使用不填充闭合形状内部的空画笔,可以编写 下列语句: CBrush* pOldBrush = dynamic_cast(pDC->SelectStockObject(NULL_BRUSH)); 和以前一样, pDC 是指向 CDC 对象的指针。在 SelectStockObject()函数中还可以使用标准钢笔 之一。标准钢笔的符号是 BLACK_PEN、NULL_PEN (不进行任何绘制 )和 WHITE_PEN。因为这个 函数处理各种各样的对象— — 如本章中介绍过的钢笔和画笔,不过它也处理字体— — 所以返回的指 针的类型是 CGdiObject*。CGdiObject 类是所有 GDI 对象的基类,因而指向这个类的指针可以用 于存储任何 GDI 对象的地址。可以将 SelectObject()或 SelectStockObject()返回的指针存储为 CGdiObject*类型,并在想要还原时传递给 SelectObject()。但是,最好将返回的指针值强制转换成 第 14 章 在窗口中绘图 585 适当的类型,以便跟踪在设备上下文中还原的对象类型。 对于使用备用画笔,然后在完成绘图后还原以前的画笔这种情况,典型的编码方式是: CBrush* pOldBrush = (CBrush*)pDC->SelectStockObject(NULL_BRUSH); // draw something... pDC->SelectObject(pOldBrush); // Restore the old brush 在本章后面的示例中将使用这样的代码。 14.3 实际绘制图形 前面介绍了绘制直线和弧的方法,现在用户要考虑如何在 Sketcher 程序中绘图。换句话说,需 要确定如何发挥用户界面的作用。 由于 Sketcher 程序是一种草图绘制工具,因此用户不需要担心坐标。绘图的最简便机制是只使 用鼠标。例如,在绘制一条直线时,用户可以确定光标的位置,在开始绘制时按下鼠标左键,然后 按住不动,通过移动光标来定义直线的终点。在按住鼠标左键移动光标时,如果能够连续地绘制直 线,就太理想了 (对于图形设计人员来说,这称为“拉橡皮筋” )。在释放鼠标左键时,这条直线将 定型。图 14-6 说明了这个过程。 按下鼠标左键 在释放鼠标左键时直线将定型 释放鼠标左键 光标移动 在光标移动时,连续更新直线 图 14-6 圆允许按照类似的方式绘制。第一次按下鼠标左键时将定义圆心,当按住左键移动光标时,程 序将跟踪光标。将连续地重新绘制圆,当前光标位置定义圆的周长上的一个点。如同绘制直线那样, 在释放鼠标左键时,圆将定型。图 14-7 说明了这个过程。 Visual C++ 2012 入门经典(第 6 版) 586 按下鼠标左键 在释放鼠标左键时圆将定型 释放鼠标左键 光标 在光标移动时,连续更新圆 图 14-7 绘制矩形和绘制直线一样容易,如图 14-8 所示。 按下鼠标左键 释放鼠标左键 在释放鼠标左键时矩形将定型 光标 在光标移动时,连续更新矩形 图 14-8 第一个点由按下鼠标左键时光标的位置定义。这是矩形的一个角。在按住左键移动鼠标时,光 标的位置将定义矩形的斜对角。实际存储的矩形是在释放鼠标左键时定义的最后一个矩形。 曲线的绘制则有点不同。任意数量的点都可以定义一条曲线。图 14-9 说明了要使用的机制。 如同其他形状那样,第一个点由按下鼠标左键时指针的位置定义。在移动鼠标时记录的连续位置由 直线段连接起来,构成这条曲线,所以鼠标轨迹定义要绘制的曲线。 第 14 章 在窗口中绘图 587 按下鼠标左键 光标路径 在释放鼠标左键时,程 序将停止跟踪光标,并 结束曲线的绘制 曲线由连接连续光标位置 的直线段定义 图 14-9 介绍了用户如何定义元素以后, 在了解实现方式时, 下一步显然是要掌握如何对鼠标进行编程。 14.4 对鼠标进行编程 要按照上面讨论的方式对绘制形状进行编程,需要详细地了解鼠标的工作过程: ● 按下鼠标键表示绘图操作开始。 ● 按住鼠标键时光标的位置提供了形状的第一个定义点。 ● 在检测到鼠标键按下后,鼠标的移动表示要绘制一个形状,光标位置提供了这个形状的第 二个定义点。 ● 释放鼠标键表示绘图操作结束,最终形状用最后的光标位置来绘制。 可以猜测到,所有这些信息都由 Windows 以发送到程序的消息的形式提供。绘制直线、矩形、 圆和曲线的实现过程几乎完全由编写消息处理程序组成。 14.4.1 鼠标发出的消息 当程序用户绘制某个形状时,他们将与特定的 文档视图发生交互作用。因此,视图类明显是存放 鼠标消息处理程序的地方。在 Class View 窗格中右 击类名 CSketcherView,然后从上下文菜单中选择 Properties 菜单项,显示它的属性窗口。然后,如果 单击消息按钮 (如果不知道哪个按钮是消息按钮, 可 以等待出现按钮工具提示 ),则出现一个消息 ID 的 列表。 这些消息 ID 是发送到视图类的标准 Windows 消息的 ID,它们的前缀是 WM_(见图 14-10)。 目前需要了解如下所示的 3 种鼠标消息(见表 14-4)。 图 14-10 Visual C++ 2012 入门经典(第 6 版) 588 表 14-4 消 息 说 明 WM_LBUTTONDOWN 按下鼠标左键时产生的消息 WM_LBUTTONUP 释放鼠标左键时产生的消息 WM_MOUSEMOVE 移动鼠标时产生的消息 这些消息彼此完全无关,它们都将发送到程序的文档视图中,即使程序没有提供它们的处理程序。 窗口很有可能在以前没有接收到 WM_LBUTTONDOWN 消息的情况下接收到 WM_LBUTTONUP 消 息。如果键按下时光标在另一个窗口上,而在释放之前移动到视图窗口,就会发生这种情况。编写这 些消息的处理程序时必须牢记这一点。 如果观察一下这个属性窗口中的列表,就可以发现还可能出现其他鼠标消息。根据应用程序的 需要,可以选择处理任何一种或者所有消息。概括地说,应当根据以前介绍的绘制直线、矩形、圆 和曲线的过程,定义如何处理目前感兴趣的这 3 种消息。 1. WM_LBUTTONDOWN 这种消息将启动绘制元素的过程。所以需要: (1) 注意元素绘制过程已经开始。 (2) 把光标当前位置作为定义元素的第一个点记录下来。 2. WM_MOUSEMOVE 这是一个中间阶段, 其中将创建和绘制当前元素的临时版本, 但是鼠标左键必须处于按下状态, 所以需要完成如下步骤: (1) 检查左键是否已经按下。 (2) 如果已经按下,则删除已经绘制的当前元素的前一个版本。 (3) 如果没有按下,则退出元素创建操作。 (4) 把光标的当前位置记录为当前元素的第二个定义点。 (5) 使用这两个定义点绘制当前元素。 3. WM_LBUTTONUP 这种消息表示绘制元素的过程已经完成,所以需要: (1) 存储由记录的第一个点定义的元素的最终版本, 同时存储鼠标键在第二个点释放时的光标位置。 (2) 记录元素绘制过程的结束。 下面将生成这 3 种鼠标消息的处理程序。 14.4.2 鼠标消息处理程序 显示 CSketcherView 类的属性窗口,单击 Messages 图标,显示这个类可以处理的消息。在视图 类的属性窗口中,单击一种鼠标消息的 ID,然后在相邻的列中单击下拉箭头,从下拉列表中选择, 即可创建鼠标消息的处理程序。例如,尝试对 WM_LBUTTONUP 消息选择 OnLButtonUp。对 消息 WM_LBUTTONDOWN 和 WM_MOUSEMOVE 消息重复这个过程。在 CSketcherView 类中生 成的函数分别是 OnLButtonDown()、OnLButtonUp()和 OnMouseMove()。现在不用修改这些函数的 名称,因为以后将针对已经在 CSketcherView 类的基类中定义的函数添加重写函数。下面分析如何 第 14 章 在窗口中绘图 589 实现这些处理程序。 首先观察 WM_LBUTTONDOWN 消息处理程序。下面是生成的框架代码: void CSketcherView::OnLButtonDown(UINT nFlags, CPoint point) { // TODO: Add your message handler code here and/or call default CView::OnLButtonDown(nFlags, point); } 可以看到其中有一个对基类处理程序的调用。如果不添加任何代码的话,那么这将确保调用这 个基类处理程序。就目前而言,在处理这个消息时,不需要调用这个基类处理程序,尽管可以这么 做。是否需要调用消息的基类处理程序要视情况而定。 通常,用于说明添加代码位置的注释具有很好的指导作用。如同目前的这个实例那样,注释建 议调用基类处理程序是可选的,在添加自己的消息处理代码时可以忽略它。注释相对于基类消息处 理程序调用的位置也很重要,因为有时必须在添加的代码前调用基类消息处理程序,有时则必须在 添加的代码后调用基类消息处理程序。 注释表明了所添加代码相对于基类消息处理程序调用的位置。 WM_LBUTTONDOWN 处理程序的参数有两个: ● nFlags 是 UINT 类型,它包含很多状态标志,表明是否按下各种键。 UINT 类型在 Windows API 中定义,对应于 32 位无符号整数。 ● point 是 CPoint 对象,它定义按下鼠标左键时光标的位置。 nFlags 的值可以是下列符号值的任意组合,见表 14-5。 表 14-5 标 志 说 明 MK_CONTROL 按下 Ctrl 键 MK_LBUTTON 按下鼠标左键 MK_MBUTTON 按下鼠标中间键 MK_RBUTTON 按下鼠标右键 MK_XBUTTON1 按下第一个额外的鼠标键 MK_XBUTTON2 按下第二个额外的鼠标键 MK_SHIFT 按下 Shift 键 如果能够检测是否按下了一个键,就可以根据键的状态,对消息进行不同的处理。 nFlags 的值 可以包含一个以上这些的指示器,每个指示器都对应于这个字中的一个特定位,所以可以使用按位 AND 运算符测试特定的键。例如,要测试是否按下了 Ctrl 键,可以编写下列代码: if(nFlags & MK_CONTROL) // Do something... 只有 nFlags 变量设置了 MK_CONTROL 位,表达式 nFlags & MK_CONTROL 的值才是 TRUE。这 样,在按下鼠标左键时,根据是否也按下了 Ctrl 键,可以采取不同的动作。由于此处使用的是按位 AND 运算符,因此对应的位将进行 AND 运算。不要把这个运算符同逻辑与运算符 &&相混淆,它 无法完成这里的运算。 传递到其他两种消息处理程序的参数和 OnLButtonDown()函数相同,针对它们生成的代码是: Visual C++ 2012 入门经典(第 6 版) 590 void CSketcherView::OnLButtonUp(UINT nFlags, CPoint point) { // TODO: Add your message handler code here and/or call default CView::OnLButtonUp(nFlags, point); } void CSketcherView::OnMouseMove(UINT nFlags, CPoint point) { // TODO: Add your message handler code here and/or call default CView::OnMouseMove(nFlags, point); } 除了函数名称以外,框架代码都一样。 如果观察一下 CSketcherView 类定义末尾的代码,那么可以看到添加了 3 个函数声明: // Generated message map functions protected: DECLARE_MESSAGE_MAP() public: afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); }; 这些声明把添加的函数标识为消息处理程序。在了解了传递到鼠标消息处理程序的信息以后, 下面将添加一些代码,使这些处理程序完成特定的工作。 14.4.3 使用鼠标绘图 对于 WM_LBUTTONDOWN 消息,我们希望把鼠标指针的当前位置作为定义元素的第一个点记 录下来,还希望记录鼠标移动后光标的位置。存储这些信息的地方显然是 CSketcherView 类,所以可 以添加数据成员到这个类中。在 Class View 窗格中右击 CSketcherView 类名,从弹出式菜单中选择 Add | Add Variable 菜单项,然后可以把需要添加的变量的细节添加到这个类中,如图 14-11 所示。 图 14-11 第 14 章 在窗口中绘图 591 类型的下拉列表只包括基本类型,所以必须把变量类型输入为 CPoint。新的数据成员应当是 protected 类型, 以防止从这个类的外面对它进行直接修改, 所以在列表中把 Access 值改为 protected。 单击 Finish 按钮,输入名称 m_FirstPoint,则创建了这个变量,在这个构造函数的初始化列表中,这个 变量的初始值被任意设置为 0。由于需要把这个初始值修改为 CPoint(0,0),因此 CSketcherView.cpp 中的代码如下: CSketcherView::CSketcherView(): m_FirstPoint(CPoint(0,0)) { // TODO: add construction code here } 这将在位置 (0,0)处把数据成员 m_FirstPoint 初始化为 CPoint 对象。现在可以把 m_SecondPoint 作为 CPoint 类型的保护成员添加到 CSketcherView 类中,用于存储元素的下一个点。并修改构造函 数的初始列表,将其初始化为 CPoint(0,0)。 现在实现 WM_LBUTTONDOWN 消息的处理程序: void CSketcherView::OnLButtonDown(UINT nFlags, CPoint point) { m_FirstPoint = point; // Record the cursor position } 这段代码仅仅记录第二个参数传递的坐标。在这种情况下,可以完全忽略第一个参数。 虽然现在还不能完成 WM_MOUSEMOVE 消息处理程序,但是可以尝试概括地写出它的代码: void CSketcherView::OnMouseMove(UINT nFlags, CPoint point) { if(nFlags & MK_LBUTTON) // Verify the left button is down { m_SecondPoint = point; // Save the current cursor position // Test for a previous temporary element { // We get to here if there was a previous mouse move // so add code to delete the old element } // Add code to create new element // and cause it to be drawn } } 检查鼠标左键是否按下是非常重要的,因为现在只希望处理这种情况下的鼠标移动。如果不进 行检查,那么在处理事件时有可能是鼠标右键处于按下状态,或者鼠标在移动时没有按下任何键。 如果按下了鼠标左键,就保存当前鼠标指针位置。这是元素的第二个定义点。剩下的事情通常 就很清楚了,但是在完成这个函数之前,还需要确定一些事情。现在还没有办法定义元素—— 由于 我们想把一个元素定义成类的对象,因此必须定义一些类。另外还需要设计一种方法,以便在创建 一个新元素时,删除原来的元素,而绘制新的元素。下面简短地介绍一些相关内容。 Visual C++ 2012 入门经典(第 6 版) 592 1. 重新绘制工作区 绘制或删除元素牵涉到重新绘制窗口的整个或部分工作区。如前所述,工作区是由 CSketcherView 类的 OnDraw()成员函数绘制的, 当 Sketcher 应用程序接收到 WM_PAINT 消息时, 将调用这个函数。 除了提供重新绘制工作区的基本消息以外, Windows 还提供了需要重新绘制的那部分工作区的信息。 在显示复杂的图像时,这可以节省大量时间,因为只需要重新绘制指定的区域,它可能只是整个区 域的一个很小的部分。 通过调用视图类的成员函数 InvalidateRect(),可以告知 Windows 应当重新绘制的特定区域。这 个函数接受两个参数,第一个参数是指向 RECT 或 CRect 对象的指针,这些对象在需要重新绘制的 工作区中定义矩形。如果这个参数的值是空值,那么将重新绘制整个工作区。第二个参数是 BOOL 值,如果准备擦除矩形的背景,那么这个 BOOL 值是 TRUE,否则为 FALSE。这个参数的默认值是 TRUE,因为在重新绘制矩形前,通常需要擦除背景,这样就可以在大部分时间里忽略它。 需要重新绘制工作区的典型情况是由于某件事情发生了变化, 从而必须重新创建工作区的内容。 例如,在工作区中移动了一个已显示的实体。在这种情况下,需要擦除背景,以便在绘制新实体前 删除已显示实体的旧图像。如果希望在已有背景的顶部绘图,只需要把 FALSE 作为第二个参数传 递到 InvalidateRect()函数。 调用 InvalidateRect()函数并不直接导致重新绘制窗口的任何一部分,这只把需要重新绘制的矩 形传递给 Windows。Windows 维护着一个更新区—— 实际上是一个矩形,它标识窗口中需要重新绘 制的整个区域。这可能源于几次 InvalidateRect()调用。调用 InvalidateRect()函数时指定的区域将添加 到当前更新区中,所以新的更新区包括旧的更新区以及表明为无效的新矩形。最后,将 WM_PAINT 消息发送到窗口,然后更新区和这个消息一起传递到窗口。在处理完 WM_PAINT 消息时,更新区 将重置为空状态。调用视图类中继承的 UpdateWindow()函数,会把一个 WM_PAINT 消息传送给视图。 因此在绘制新创建的形状时,必须完成以下工作: (1) 确保视图类中的 OnDraw()函数在重新绘制窗口时包括新创建的元素。 (2) 调用 InvalidateRect()函数,其第一个参数是指向待重新绘制元素的边界矩形的指针。 (3) 通过调用视图的继承函数 UpdateWindow(),把一个 WM_PAINT 消息传送给视图。 类似地,如果要从窗口工作区中删除一个形状,需要完成下列工作: (1) 从 OnDraw()函数将要绘制的项中删除这个形状。 (2) 调用 InvalidateRect()函数,其第一个参数指向待删除形状的边界矩形。 (3) 通过调用视图的继承函数 UpdateWindow(),把一个 WM_PAINT 消息传送给视图。 由于自动擦除了更新区的背景,因此只要 OnDraw()函数不再次绘制这个形状,这个形状就会消 失。当然,这意味着必须能够获得界定所创建形状的矩形,所以要包括一个函数,把这个矩形返回 为定义 Sketcher 元素的类的成员。 2. 定义元素的类 我们需要以某种方式把草图元素存储在一个文档中。如果要使草图具有永久性,还必须把这个 文档存储在一个文件中,以便今后检索。后面将详细地讨论文件操作,就目前而言,知道 MFC 类 CObject 包括所需要的工具就足够了,所以我们将把 CObject 作为草图类的基类使用。 另外还有一个问题,就是无法提前知道用户创建的元素类型的顺序。 Sketcher 程序必须能够处 第 14 章 在窗口中绘图 593 理任何顺序的元素。这意味着,使用基类指针存储最新创建的元素的地址可能会使事情简单一些。 调用元素类的函数时,不需要指定元素是哪种类型。例如,绘制一个元素时不需要知道它是什么。 只要是通过基类指针访问这个元素,就可以始终使用虚函数获得要绘制的这个元素。现在只需要确 保定义元素的类能够共享一个公共基类,并在这个类中把所有要在运行时通过多态性调用的函数声 明为虚函数。这表明,可以按照如图 14-12 所示的方法组织元素类。 图 14-12 图 14-12 中的箭头指向每种情况下的基类。 如果需要添加另一种元素类型, 只需要要从 CElement 派生另一种类。 在 Class View 窗格中右击 Sketcher,从弹出式菜单中选择 Add | Class 菜单项, 即可创建 CElement 类。从安装的模板列表中选择 MFC,然后在中心窗格中选择 MFC Class 项。在这个对话框中单击 Add 按钮,将显示另一个对话框,其中可以指定类名称,并选择基类,如图 14-13 所示。 图 14-13 在类名称框中填写 CElement,并且从下拉列表中把基类选择为 CObject。单击 Finish 按钮,则 生成 CElement 类定义的代码: Visual C++ 2012 入门经典(第 6 版) 594 #pragma once // CElement command target class CElement : public CObject { public: CElement(); virtual ~CElement(); }; 声明的唯一成员是一个构造函数和一个虚析构函数,这些函数的框架定义在 Elements.cpp 文件 中。可以看到,这个 Class Wizard 包括一个 #pragma once 指令,以确保头文件的内容不能在另一个 文件中被包括多次。 使用完全相同的过程可以添加其他元素类。因为其他元素类把 CElement 而不是 MFC 类作为基 类,所以应当把类类别选作 C++,把模板选作 C++ class。选择 Virtual destructor 选项。对于 CLine 类,Class Wizard 窗口应当如图 14-14 所示。 图 14-14 默认情况下, Class Wizard 为头文件和 .cpp 文件提供的名称分别是 Line.h 和 Line.cpp,不过可 以 修改这些名称,对这些文件使用不同的名称,或者把代码添加到已有的文件中。在基类定义中给 CElement.h 添加一个 #include 指令。在创建了 CLine 类定义以后,对 CRectangle、CCircle 和 CCurve 进行相同的操作。当完成这些工作以后,在 .h 文件中应当看到 CElement 类的所有 4 个子类的定义, 针对每个子类都声明了一个构造函数和一个虚析构函数。 在视图中存储临时元素 在前面讨论如何绘制形状时已经介绍过,按下鼠标左键以后,拖动鼠标将创建和绘制一系列临时 元素对象。所有元素的基类都是 CElement,就可以添加 std::shared_ptr类型的智能指针, 来指向这个视图类,用以存储临时元素的地址。可以把 unique_ptr 看作它的替代项,但我们最终需要 第 14 章 在窗口中绘图 595 在鼠标事件处理函数中访问指向元素的指针,而这个指针存储在文档对象的一个容器中,而 unique_ptr 不允许这么做。 首先,在#pragma once 指令的后面给 memory 头文件和 Element.h 添加#include 指令。再右击 CSketcherView 类,选择 Add | Add Variable 选项。 m_pTempElement 变量的类型应当是 std::shared_ ptr,和以前添加的两个数据成员一样,它应当是 protected。可以在 WM_MOUSEMOVE 消息处理程序中使用 m_pTempElement 指针,测试以前的临时元素是否存在,因为在没有临时元素 时,将这个指针安排为空。 3. CElement 类 现在可以逐步填写这个元素类的定义,为 Sketcher 应用程序添加越来越多的功能— — 不过现在 需要做什么呢?有些数据项 (如颜色和位置 )显然对于所有类型的元素都是通用的,所以可以把它们 放在 CElement 基类中,以便在每个派生类中继承它们。但是,在定义特定元素属性的类中,有些数 据成员却极其不同,所以需要在它们所属的特定派生类中声明这些成员。 CElement 类包含要在派生类中实现的虚函数,以及在所有派生类中都相同的数据和函数成员。 虚函数是通过 CElement*指针自动为特定对象选择的函数。这时可以使用 Add Member Wizard 在 CElement 类中添加这些成员,但需要手动修改。目前,可以把 CElement 类定义修改为: class CElement: public CObject { protected: CPoint m_StartPoint; // Element position int m_PenWidth; // Pen width COLORREF m_Color; // Color of an element CRect m_EnclosingRect; // Rectangle enclosing an element public: virtual ~CElement(); virtual void Draw(CDC* pDC) {} // Virtual draw operation // Get the element enclosing rectangle const CRect& GetEnclosingRect() const { return m_EnclosingRect; } protected: // Constructors protected so they cannot be called outside the class CElement(); CElement(const CPoint& start, COLORREF color, int penWidth = 1); }; 这段代码把构造函数 CElement 的访问方式从 public 修改为 protected,以防止从 CElement 类的 外部调用这个函数,新构造函数只能在派生类中调用,它有 3 个参数,笔宽使用默认值,因为笔宽 大多为 1。现在,派生类继承的成员是存储元素的位置、颜色和笔宽的数据成员,以及界定元素占 用区域的边界矩形。 在类中还有两个成员函数定义: ● GetEnclosingRect()返回 m_EnclosingRect,需要使元素占用的区域失效时,就使用这个函数。 ● 虚函数 Draw()在派生类中实现,用于绘制元素。 Draw()函数要求将一个指向 CDC 对象的指 针传递给它,以访问需要在设备环境中绘图的函数。 Visual C++ 2012 入门经典(第 6 版) 596 您可能想把 Draw()成员声明为 CElement 类中的纯虚函数, 让派生类定义它—— 毕竟, 它在这个 类中没有任何有意义的内容。虽然通常可以这么做,但是 CElement 类将从 CObject 继承一个称为序 列化的工具,以后在文件中存储元素对象时将使用它。序列化要求, CElement 不是抽象类,以便创 建这种类型的实例。如果想使用 MFC 的序列化能力,那么类一定不能是抽象的,还必须有无参数 的构造函数。 可以在 Element.cpp 中添加新构造函数的定义: CElement::CElement(const CPoint& start, COLORREF color, int penWidth) : m_StartPoint(start), m_PenWidth(penWidth), m_Color(color) {} 所有成员都在初始化列表中定义了,所以构造函数体中不需要任何代码。 4. CLine 类 可以把 CLine 类的定义修改为: class CLine: public CElement { public: virtual ~CLine(void); virtual void Draw(CDC* pDC) override; // Function to display a line // Constructor for a line object CLine(const CPoint& start, const CPoint& end, COLORREF aColor); protected: CPoint m_EndPoint; // End point of line protected: CLine(void); // Default constructor should not be used }; 直线用两个点定义, 分别是继承自 CElement 的 m_StartPoint 和 m_EndPoint。这个类有一个 public 构造函数,它的参数值将定义直线,而无参数的默认构造函数已移动到这个类的 protected 部分,以 防从外部使用它。构造函数的前两个参数是 const 引用,以免在创建 CLine 对象时复制 CPoint 对象。 OnDraw()成员声明中使用 override,是为了确保编译器可验证,它正确覆盖了基类函数。 实现 CLine 类 把 CLine 成员函数的实现添加到由 Class Wizard 创建的 Line.cpp 文件中。 stdafx.h 文件已经包括 在这个文件中,从而使得标准系统头文件在这个文件中可用,并预编译 stdafx.h 文件的内容。预计 不会修改的头文件可以包含在 stdafx.h 文件中,使它们也预编译。现在就可以在 Line.cpp 文件中添 加 CLine 构造函数的基本代码了。 CLine 类构造函数 CLine 类构造函数的代码是: // CLine class constructor 注意:序列化是将对象写入到文件的通用术语。 第 14 章 在窗口中绘图 597 CLine::CLine(const CPoint& start, const CPoint& end, COLORREF color) : CElement(start, color), m_EndPoint(end) {} 首先调用 CElement 类的构造函数,初始化继承来的 m_StartPoint 和 m_Color 成员。在这个构造 函数中忽略了第三个参数,定义钢笔宽度默认设置为 1,而 Cline 的 m_EndPoint 成员在构造函数的 初始化列表中初始化,以后会添加更多的代码。 绘制直线 绘制直线所需的 CPen 对象对所有元素而言都是相同的,在 CElement 类的定义中可以添加一个 函数来创建它: // Create a pen void CreatePen(CPen& aPen) { if(!aPen.CreatePen(PS_SOLID, m_PenWidth, m_Color)) { // Pen creation failed AfxMessageBox(_T("Pen creation failed."), MB_OK); AfxAbort(); } } 只有派生类对象才调用这个函数,所以可以把这个函数定义放在类的 protected 部分。在类定义 中定义该函数,会使它成为内联函数。引用参数允许函数修改在调用函数中定义的 aPen 对象。给 CPen 对象调用 CreatePen(),会创建一个钢笔,并把它关联到对象上。 创建合适颜色的新钢笔,直线宽度由 m_PenWidth 指定,只不过这次要保证它能使用。在钢笔 不能使用这种不大可能发生的情况中,最可能的原因是内存耗尽,这是一个严重的问题。这几乎总 是由程序中的错误引起的,所以应首先编写调用 AfxMessageBox()的函数(它是一个显示消息框的 MFC 全局函数),然后调用 AfxAbort()终止这个程序。 AfxMessageBox()的第一个参数指定要出现的 消息, 第二个参数指定消息框应当有一个 OK 按钮。 要获得有关这两个函数的详细信息, 可以在 Editor 窗口中把光标放在函数名内,然后按 F1 键。 如果基类中没有这个函数,就必须在每个派生类的 Draw()函数中重复这些代码。 尽管在绘制直线时需要考虑使用的颜色,但是 CLine 类的 Draw()函数也不是太难: // Draw a CLine object void CLine::Draw(CDC* pDC) { // Create a pen for this object and initialize it CPen aPen; CreatePen(aPen); CPen* pOldPen = pDC->SelectObject(&aPen); // Select the pen 设备上下文、钢笔、画笔、字体和其他用于在窗口中绘图的对象都是 GDI 对象。 不能复制或指定 GDI 对象,否则,代码就不会编译。这表示,不能将 GDI 对象按值 传递给另一个函数。 Visual C++ 2012 入门经典(第 6 版) 598 // Now draw the line pDC->MoveTo(m_StartPoint); pDC->LineTo(m_EndPoint); pDC->SelectObject(pOldPen); // Restore the old pen } 调用继承的 CreatePen()函数,会在 aPen 中建立钢笔。将钢笔选择到设备上下文中后,就把当前 位置移动到直线的起点,这个点定义在 m_StartPoint 数据成员中,然后从这个点将直线绘制到 m_EndPoint。最后在设备上下文中还原旧钢笔,绘图到此结束。 创建边界矩形 乍一看,获得一个形状的边界矩形好像很简单。例如,直线始终是封闭矩形的对角线,圆由它 的封闭矩形定义,但还是有一些稍微复杂的问题。形状必须完全在封闭矩形的内部;否则,部分形 状可能无法绘制出来,所以在创建边界矩形时,必须考虑形状如何相对于其定义参数来绘制,以及 绘图时使用的线宽度。此外,如何调整定义边界矩形的坐标取决于映射模式,所以也必须考虑这个 方面。 在计算不同映射模式中边界矩形的坐标时,计算方法之间的区别只与 y 坐标有关; x 坐标的计 算对于所有映射模式都一样:都是从矩形左上角的 x 坐标中减去线宽,再将它加到矩形右下角的 x 坐标中。在 MM_TEXT 映射模式中,要从定义矩形的左上角的 y 坐标中减去线宽,但是在 MM_LOENGLISH 映射模式(以及其他所有映射模式 )中,由于 y 轴在相反的方向上增大,因此需要 在定义矩形的左上角的 y 坐标中加上线宽。 下面要在每种形状的构造函数中创建该形状的边界矩形。 规范化的矩形 规范化矩形的 left 值小于 right 值,top 值小于 bottom 值。如果矩形不是规范化的,在 CRect 对 象上执行的函数就不会正确执行。例如, CRect 类的 InflateRect()成员函数从矩形的 top 和 left 成员 中减去参数值,给 bottom 和 right 成员加上这些值。这意味着,如果矩形不是规范化的,对它应用 InflateRect(),矩形实际上有可能缩小。 只要矩形是规范化的, 则无论在什么映射模式中, InflateRect() 函数都可以正常工作。 通过调用 CRect 对象的 NormalizeRect()成员,可以确保这个对象是规范化的。 5. 计算直线的封闭矩形 现在,在 CLine 构造函数内编写计算封闭矩形的代码: CLine::CLine(const CPoint& start, const CPoint& end, COLORREF color): CElement(start, color), m_EndPoint(end) { // Define the enclosing rectangle m_EnclosingRect = CRect(start, end); m_EnclosingRect.NormalizeRect(); 需要提醒的是, CRect 对象的各个数据成员是 left 和 top (分别存储左上角的 x 和 y 坐标)以及 right 和 bottom (分别存储右下角的 x 和 y 坐标)。这些数据成员都是公共成 员,所以可以直接访问它们。一个常见的错误是把坐标对写成 (top, left),而不是正确 的顺序(left, top)。 第 14 章 在窗口中绘图 599 m_EnclosingRect.InflateRect(m_PenWidth, m_PenWidth); } 调用 m_EnclosingRect 对象的 NormalizeRect()成员,则无论直线的起点和终点的相对位置如何, 都能确保边界矩形的 left 和 top 值分别小于 right 和 bottom 值。调用 InflateRect(),使边界矩形的尺 寸增大一个线宽。第一个参数是左边和右边的调整量,第二个参数是顶部和底部的调整量。 InflateRect()的一个重载版本只有一个 SIZE 类型的参数,它也接受 CSize 对象,该对象封装了 SIZE 结构。CSize 对象通过 long 类型的公共成员 cx 和 cy 定义了大小。 6. CRectangle 类 在定义矩形对象时使用的数据和定义直线时相同—— 矩形对角点, 可以把 CRectangle 类定义为: class CRectangle: public CElement { public: virtual ~CRectangle(void); virtual void Draw(CDC* pDC) override; // Function to display a rectangle // Constructor for a rectangle object CRectangle(const CPoint& start, const CPoint& end, COLORREF color); protected: CPoint m_BottomRight; // Bottom-right point for the rectangle CRectangle(void); // Default constructor - should not be used }; 无参数构造函数现在是 protected 类型,以防止外界使用它。这个矩形的定义非常简单,只包括 一个构造函数、一个虚函数 Draw()和这个类的 protected 部分中的一个无参数构造函数。 CRectangle 类构造函数 新的 CRectangle 类构造函数的代码比 Cline 复杂一些: // CRectangle constructor CRectangle:: CRectangle ( const CPoint& start, const CPoint& end, COLORREF color) : CElement(start, color) { // Normalize the rectangle defining points m_StartPoint = CPoint((std::min)(start.x, end.x),(std::min)(start.y, end.y)); m_BottomRig ht = CPoint((std::max)(start.x, end.x), (std::max)(start.y, end.y)); // Ensure width and height between the points is at least 2 if((m_BottomRight.x - m_StartPoint.x) < 2) m_BottomRight.x = m_StartPoint.x + 2; if((m_BottomRight.y - m_StartPoint.y) < 2) m_BottomRight.y = m_StartPoint.y + 2; // Define the enclosing rectangle m_EnclosingRect = CRect(m_StartPoint, m_BottomRight); m_EnclosingRect.InflateRect(m_PenWidth, m_PenWidth); } Visual C++ 2012 入门经典(第 6 版) 600 在初始化列表中调用 CElement 构造函数,以初始化钢笔的颜色和线宽。 CElement 构造函数还 初始化了 m_StartPoint,但后面要替换它。可以只绘制宽度和高度至少是 2 的 CRect 对象,在安排了 定义规范化矩形的点后,在必要时调整两个定义点的坐标。由于调整了这些定义点,所以不需要调 用 NormalizeRect()。std::min()和 std::max()是 algorithm 头文件中定义的模板函数,它们分别返回其 参数的最小和最大值。在 Rectangle.cpp 中给 algorithm 头文件添加一个#include 指令。 绘制矩形 CDC 类的 Rectangle()成员可以绘制矩形。这个函数将绘制闭合图形,然后利用当前画笔进行填充。 您可能只想绘制矩形的轮廓,这时只要将 NULL_BRUSH 选入设备上下文即可。CDC 还有一个函数 PolyLine(),它根据一组点绘制由多条线段组成的形状,也可以使用 LineTo()绘制矩形的 4 条边,但是 最容易的方法是使用 Rectangle()函数: // Draw a CRectangle object void CRectangle::Draw(CDC* pDC) { // Create a pen for this object and initialize it CPen aPen; CreatePen(aPen); // Select the pen and the null brush CPen* pOldPen = pDC->SelectObject(&aPen); CBrush* pOldBrush = dynamic_cast(pDC->SelectStockObject(NULL_BRUSH)); // Now draw the rectangle pDC->Rectangle(m_StartPoint.x, m_StartPoint.y, m_BottomRight.x, m_BottomRight.y); pDC->SelectObject(pOldBrush); // Restore the old brush pDC->SelectObject(pOldPen); // Restore the old pen } 在设置了钢笔和画笔以后,就调用 Rectangle()函数,绘制矩形。其参数是矩形的左下角和右上 角坐标。这个函数有一个重载版本,它把 CRect 对象作为参数来指定矩形。剩下的工作就是在绘制 结束后,还原设备上下文的旧钢笔和画笔。 7. CCircle 类 CCircle 类的接口类似于 CRectangle 类。使用 CDC 类的 Ellipse()成员可以绘制圆,这个成员绘 制用一个矩形来界定的椭圆。只要该边界矩形是正方形,该椭圆就是圆。圆用两个定义点来定义, 分别是圆心和圆周上的一点。所以这个类的定义是: class CCircle: public CElement { 代码中围绕 std::min 和 std::max 的圆括号是必需的。在其他 C++编译器中, 这不 常见,但它是 Visual C++中的一种特殊情况。 windows.h 头文件为 min 和 max 定义了 宏,且不带括号,在编译器启动之前,预处理器会替代这些宏。 这会使编译器生成错 误消息。圆括号可防止预处理器替代宏。 第 14 章 在窗口中绘图 601 public: virtual ~CCircle(void); virtual void Draw(CDC* pDC) override; // Function to display a circle // Constructor for a circle object CCircle(const CPoint& start, const CPoint& end, COLORREF color); protected: CPoint m_BottomRight; // Bottom-right point for defining rectangle CCircle(void); // Default constructor - should not be used }; 这段代码定义了一个公共构造函数,它使用绘图颜色,根据两个点创建圆,并且把无参数构造 函数设置为 protected,以防止外界使用它。另外在这个类定义中还添加了这个绘制圆的重载函数的 声明。 实现 CCircle 类 在创建圆时,按下鼠标左键时的点将成为圆心,移动光标后,释放鼠标左键时的点将是圆周上 的一个点。构造函数的工作是从这些点中获得边界矩形的角点。 CCircle 类构造函数 释放鼠标左键时的点可以位于圆周上的任何地方,所以需要计算指定封闭矩形的点的坐标,如 图 14-15 所示。 距离是 距离是 此处按下鼠标左键 半径 r 此处释放鼠标左键 边界矩形的角点 图 14-15 由图 14-15 可以看出,我们可以计算封闭矩形的左上角和右下角相对于圆心 (x1,y1)的坐标。假设 映射模式是 MM_TEXT,计算(left,top)点的坐标时,只需要要从圆心的坐标中减去半径。类似地, 把圆心的 x 和 y 坐标分别加上半径,就可以得到 (right,bottom)点的坐标。半径可以计算为图 14-15 中表达式的平方根。因此,可以把 CCircle 类构造函数的代码编写为: Visual C++ 2012 入门经典(第 6 版) 602 // Constructor for a circle object CCircle::CCircle(const CPoint& start, const CPoint& end, COLORREF color) : CElement(start, color) { // Calculate the radius using floating-point values // because that is required by sqrt() function (in cmath) long radius = static_cast (sqrt( static_cast((end.x-start.x)*(end.x-start.x)+ (end.y-start.y)*(end.y-start.y)))); if(radius < 1L) radius = 1L; // Circle radius must be >= 1 // Define left-top and right-bottom points of rectangle for MM_TEXT mode m_StartPoint = CPoint(start.x - radius, start.y - radius); m_BottomRight = CPoint(start.x + radius, start.y + radius); // Define the enclosing rectangle m_EnclosingRect = CRect(m_StartPoint.x, m_StartPoint.y, m_BottomRight.x, m_BottomRight.y); m_EnclosingRect.InflateRect(m_PenWidth, m_PenWidth); } 要使用 sqrt()函数,必须在 Circle.cpp 文件的开始处给 cmath 头文件添加一个 #include 指令。最 大坐标值是 32 位,CPoint 成员 x 和 y 声明为 long 型,所以把 sqrt()函数返回的值转换为 long 类型会 得到准确的结果。平方根计算的结果是 double 型,由于想把它作为整数使用,因此要把它强制转换 成 long 型。半径至少应是 1,否则 CDC 的 Ellipse()函数就什么都不绘制。 在 m_StartPoint 中存储 CPoint 对象和边界矩形左上角的坐标, 该坐标是从圆心坐标中减去 radius 得到的。m_BottomRight 点则是给圆心坐标加上 radius 得到的。 绘制圆 Draw()函数在 CCircle 类中的实现如下所示: // Draw a circle void CCircle::Draw(CDC* pDC) { // Create a pen for this object and initialize it CPen aPen; CreatePen(aPen); CPen* pOldPen = pDC->SelectObject(&aPen); // Select the pen // Select a null brush CBrush* pOldBrush = dynamic_cast(pDC->SelectStockObject(NULL_BRUSH)); // Now draw the circle pDC->Ellipse(m_StartPoint.x, m_StartPoint.y, m_BottomRight.x, m_BottomRight.y); pDC->SelectObject(pOldPen); // Restore the old pen pDC->SelectObject(pOldBrush); // Restore the old brush } 在设备上下文中选择了具有适当颜色的钢笔和空画笔以后,将通过调用 Ellipse()函数绘制圆。 参数是边界矩形的角点坐标。Ellipse()函数的一个重载版本接受 CRect 参数。 第 14 章 在窗口中绘图 603 8. CCurve 类 CCurve 类不同于其他类, 因为它必须能够处理数量可变的定义点。 曲线由两个或多个点来定义, 这些点可以存储在 STL 容器中。不需要删除曲线上的点,所以 vector容器是存储这些点的 一个不错的候选。第 10 章介绍了 vector模板,但是,在完成 CCurve 类的定义之前,需要更详 细地探讨一下用户如何创建和绘制曲线。 绘制曲线 绘制曲线与绘制直线、矩形或圆并不相同。当绘制这些形状时,按下鼠标左键并移动光标时, 就在创建一系列不同的元素,它们具有一个相同的参考点——即按下鼠标左键时的那个点。而当绘 制曲线时,情况就不同了,如图 14-16 所示。 X 轴 前两个点定义一条 基本曲线 每增加一个点就另外 定义一个线段 Y 轴 最小 y 最大 y 最大 x最小 x 用 MM_TEXT 映射模式绘制曲线 图 14-16 当移动光标来绘制一条曲线时,并不是在创建一系列新的曲线,而是在延伸同一条曲线,因此 后续的每个新光标位置都向曲线添加另一个线段。因此,一旦从 WM_LBUTTONDOWN 消息和第 一条 WM_MOUSEMOVE 消息获得两个点,就需要创建一个 CCurve 对象。随后的鼠标移动消息所 定义的点则向已有的 CCurve 对象定义其他线段。为此,需要向 CCurve 类添加一个函数 AddSegment(),用来对曲线进行延伸。 需要考虑的另一要点是如何计算封闭矩形。定义封闭矩形时,需要从所有定义点中获得(最小 x, 最小 y)对,以便建立矩形的左上角;再从所有定义点中获得 (最大 x, 最大 y)对,以便建立矩形的 右下角。生成封闭矩形的最简单的方法是,在构造函数中根据前两个点来计算它,然后向曲线添加 点时,在 AddSegment()函数中以增量方式重新计算它。 在 Curve.h 文件中,给 vector 头文件添加一个#include 指令。可以将 CCurve 类定义修改为: class CCurve: public CElement { public: Visual C++ 2012 入门经典(第 6 版) 604 virtual ~CCurve(void); virtual void Draw(CDC* pDC) override; // Function to display a curve // Constructor for a curve object CCurve(const CPoint& first, const CPoint& second, COLORREF color); void AddSegment(const CPoint& point); // Add a segment to the curve protected: std::vector m_Points; // Points defining the curve CCurve(void); // Default constructor - should not be used }; 可以在 Curve.cpp 文件中为构造函数添加如下定义: // Constructor for a curve object CCurve::CCurve(const CPoint& first, const CPoint& second, COLORREF color) : CElement(first, color) { // Store the second point in the vector m_Points.push_back(second); m_EnclosingRect = CRect( (std::min)(first.x, second.x), (std::min)(first.y, second.y), (std::max)(first.x, second.x), (std::max)(first.y, second.y)); m_EnclosingRect.InflateRect(m_PenWidth, m_PenWidth); } 构造函数的参数包括前两个定义点,因此构造函数定义了一个只有一条线段的曲线。第一个点 由 CElement 构造函数存储在 m_StartPoint 成员中,第二个点存储在 m_Points 矢量中。 push_back() 函数可以在矢量末尾添加元素。创建封闭矩形时,使用了 std::min()和 std::max()模板函数。所以需要 在 Curve.cpp 文件中,给 algorithm 头文件添加一个#include 指令。 绘制曲线的 Draw()函数可以定义为: // Draw a curve void CCurve::Draw(CDC* pDC) { // Create a pen for this object and initialize it CPen aPen; CreatePen(aPen); CPen* pOldPen = pDC->SelectObject(&aPen); // Select the pen // Now draw the curve pDC->MoveTo(m_StartPoint); for(auto& point : m_Points) pDC->LineTo(point); pDC->SelectObject(pOldPen); // Restore the old pen } Draw()函数必须为曲线提供任意数量的点。一旦设置了钢笔,第一步就是在设备上下文中将当 前位置移动到 m_StartPoint。曲线的各个线段在 for 循环中绘制,每条线段对应于矢量 m_Points 中的 一个点。LineTo()操作将当前位置移动到它绘制的直线的末端,这里每次调用函数时都从当前位置 到矢量中的下一个点绘制一条直线。按照这种方式,按顺序绘制组成曲线的所有线段。 第 14 章 在窗口中绘图 605 可以如下面这样实现 AddSegment()函数: // Add a segment to the curve void CCurve::AddSegment(const CPoint& point) { m_Points.push_back(point); // Add the point to the end // Modify the enclosing rectangle for the new point m_EnclosingRect.DeflateRect(m_PenWidth, m_PenWidth); m_EnclosingRect = CRect((std::min)(point.x, m_EnclosingRect.left), (std::min)(point.y, m_EnclosingRect.top), (std::max)(point.x, m_EnclosingRect.right), (std::max)(point.y, m_EnclosingRect.bottom)); m_EnclosingRect.InflateRect(m_PenWidth, m_PenWidth); } 这段代码给矢量添加 point,并调整封闭矩形,确保左上角的点是最小的 x 和最小的 y,右下角 的点是最大的 x 和最大的 y。完整曲线的封闭矩形的左上角是从所有定义点中获得的 (最小 x, 最小 y)对,右下角是从所有定义点中获得的 (最大 x, 最大 y)对。接着要将得到的矩形放大一个线宽。因 此,必须将 m_EnclosingRect 减小一个线宽,才能找到左上角和右下角的坐标。否则就得不到正确 的封闭矩形。调用 CRect 的 DeflateRect()成员函数,会执行与 InflateRect()相反的操作,所以这里需 要调用 DeflateRect()。 9. 完成鼠标消息处理程序 现在可以回到 WM_MOUSEMOVE 消息处理程序,填充一些细节。在 Class View 窗格中选择 CSketcherView,然后双击处理程序名 OnMouseMove(),就可以找到这个消息处理程序。 鼠标进行移动时,这个处理程序只绘制元素的一系列临时版本,因为最终的元素是在释放鼠标 左键时创建的。因此,可以把像拉橡皮筋那样绘制临时元素的过程看作是这个函数的局部操作,而 由视图的 OnDraw()函数成员绘制元素的最终版本。这种方法使橡皮筋元素的绘制变得非常有效率, 因为它不涉及 CSketcherView 中最后负责绘制完整文档的 OnDraw()函数。 利用 CDC 类的一个成员 SetROP2(),很容易实现这种方法,这个成员函数特别适合于橡皮筋 操作。 设置绘图模式 在与 CDC 对象相关联的设备上下文中, SetROP2()函数为所有后续输出操作设置绘图模式。这 个函数名中的“ ROP”代表光栅操作 (Raster OPeration),因为绘图模式的设置将应用于光栅显示器。 可能您要问, “SetROP1()是什么呢?”—— 没有这种函数。 SetROP2()这个函数名表示 Set Raster OPeration to(设置光栅操作为),不是数字 2! 绘图模式确定绘图时使用的钢笔颜色如何与背景色相组合,以产生要显示的实体的颜色。绘图 模式由这个函数的一个参数指定,它可以是表 14-6 中的任一个值。 表 14-6 绘 图 模 式 效 果 R2_BLACK 所有绘图颜色都是黑色 R2_WHITE 所有绘图颜色都是白色 Visual C++ 2012 入门经典(第 6 版) 606 (续表) 绘 图 模 式 效 果 R2_NOP 不进行任何绘图操作 R2_NOT 绘图颜色是屏幕颜色的反色。这将确保输出清晰可见,因为它防止绘图颜色与背景 色相同 R2_COPYPEN 绘图颜色是钢笔颜色。如果不进行设置,这就是默认的绘图模式 R2_NOTCOPYPEN 绘图颜色是钢笔颜色的反色 R2_MERGEPENNOT 绘图颜色是钢笔颜色和背景色的反色“相或”以后产生的颜色 R2_MASKPENNOT 绘图颜色是钢笔颜色和背景色的反色“相与”以后产生的颜色 R2_MERGENOTPEN 绘图颜色是背景色和钢笔颜色的反色“相或”以后产生的颜色 R2_MASKNOTPEN 绘图颜色是背景色和钢笔颜色的反色“相与”以后产生的颜色 R2_MERGEPEN 绘图颜色是背景色和钢笔颜色“相或”以后产生的颜色 R2_NOTMERGEPEN 绘图颜色是 R2_MERGEPEN 颜色的反色 R2_MASKPEN 绘图颜色是背景色和钢笔颜色“相与”以后产生的颜色 R2_NOTMASKPEN 绘图颜色是 R2_MASKPEN 颜色的反色 R2_XORPEN 绘图颜色是钢笔色和背景色“异或”以后产生的颜色,结果是由钢笔色和背景色中 的 RGB 成分组成的颜色,但不是这两种颜色 R2_NOTXORPEN 绘图颜色是 R2_XORPEN 颜色的反色 每种符号都是 int 类型的预定义值。虽然此处有很多选项,但是可以带来一些特殊效果的选项 是最后的 R2_NOTXORPEN 选项。 将绘图模式设置为 R2_NOTXORPEN 以后,第一次在默认的白色背景上绘制特定形状时,通常 是以指定的钢笔颜色绘制。 如果再次绘制的相同形状覆盖在第一次绘制的形状上, 那么形状将消失, 因为这次形状的颜色对应于钢笔颜色和它自身颜色“异或”以后产生的颜色。这时产生的绘图颜色 是白色。利用一个示例可以看得更为清楚一些。 白色是由相同比例、 “最大”数量的红色、蓝色和绿色 (255, 255, 255)构成的,所以每种成分值 的位都是 1。为了简化问题,可以把白色表示为 (1,1,1)—— 这 3 个值代表颜色的 RGB 成分。在相 同的方案中,红色定义为(1,0,0),而不是(255, 0, 0)。这些颜色的组合如表 14-7 所示。 表 14-7 计 算 过 程 R G B 背景色(白色,窗口背景色) 1 1 1 钢笔颜色(红色) 1 0 0 XOR(产生红色成分) 0 1 1 NOT XOR(产生红色,绘制颜色) 1 0 0 因此,第一次在白色背景上绘制红色直线时,如表 14-7 的最后一行所示,直线显示的颜色是红 色。如果第二次绘制的相同直线覆盖在现有的直线上,那么重写的背景像素将是红色。产生的绘图 颜色将作如表 14-8 所示的计算。 第 14 章 在窗口中绘图 607 表 14-8 计 算 过 程 R G B 背景色(红色最后一次绘制的像素) 1 0 0 钢笔(红色) 1 0 0 XOR(产生黑色) 0 0 0 NOT XOR(产生白色,窗口背景色) 1 1 1 如最后一行所示,直线显示的颜色是白色,因为窗口的背景色是白色,所以直线消失。 此处需要注意使用正确的背景色。应当看到,使用白色钢笔在红色背景上绘图的效果不太好, 如第一次绘制的形状是红色,其结果是看不见的。它第二次出现时是白色。如果在黑色背景上进行 绘制,如同在白色背景上那样,形状将出现,然后消失,但是它们不是以选择的钢笔颜色绘制的。 编写 OnMouseMove()处理程序 首先在鼠标移动消息后面添加创建元素的代码。因为打算利用这个处理程序函数绘制元素,所 以需要创建一个可在其中绘图的设备上下文。这时最便于使用的类是 CClientDC,它是 CDC 的派生 类。这个类型的对象会在工作完成后自动销毁设备上下文。所以可以创建一个 CClientDC 对象,使 用它,然后不再管它。创建 CClientDC 对象时,可以给 CClientDC 构造函数传递一个指向设备上下 文所在窗口的指针, 它创建的设备上下文对应于窗口的工作区, 所以如果传递一个指向视图的指针, 就得到了我们需要的。 绘制元素的逻辑有点复杂。只要形状不是曲线,就可以使用 R2_NOTXORPEN 模式绘制一个形 状。当创建曲线时,我们并不想在每次移动鼠标时都绘制一个新曲线,而只是想将曲线延伸出一个 新线段。这就意味着必须将绘制曲线的情况视为一个例外。我们也想当旧元素存在时删除它,但只 有当它不是曲线时才行。下面的代码说明了如何实现此功能: void CSketcherView::OnMouseMove(UINT nFlags, CPoint point) { // Define a Device Context object for the view CClientDC aDC(this); // DC is for this view if(nFlags & MK_LBUTTON) // Verify the left button is down { m_SecondPoint = point; // Save the current cursor position if(m_pTempElement) { // An element was created previously if(ElementType::CURVE == GetDocument()->GetElementType()) // A curve? { // We are drawing a curve so add a segment to the existing curve std::static_pointer_cast(m_pTempElement)->AddSegment(m_ SecondPoint); m_pTempElement->Draw(&aDC); // Now draw it return; // We are done } else { // If we get to here it’s not a curve so // redraw the old element so it disappears from the view aDC.SetROP2(R2_NOTXORPEN); // Set the drawing mode m_pTempElement->Draw(&aDC); // Redraw the old element to erase it Visual C++ 2012 入门经典(第 6 版) 608 } } // Create a temporary element of the type and color that // is recorded in the document object, and draw it m_pTempElement.reset(CreateElement()); m_pTempElement->Draw(&aDC); } } 第一行新代码创建了一个局部 CClientDC 对象。传递给这个构造函数的 this 指针标识当前视图 对象,所以 CClientDC 对象是一个设备上下文,对应于当前视图的工作区。这个对象有我们需要的 所有绘图函数,因为它们都是从 CDC 类继承的。 如果指针 m_pTempElement 不是 nullptr,则说明存在一个旧的临时元素。根据 m_pTempElement 是否指向曲线,来执行不同的操作,所以需要检查用户是否在绘制曲线。 shared_ptr类型支持转 换到 bool 类型,所以可以在 if 表达式中使用它。对文档对象调用 GetElementType()函数,以获得当 前元素的类型。如果当前元素的类型是 ElementType::CURVE,则强制将 m_pTempElement 转换为 shared_ptr类型,以便为对象调用 AddSegment()函数,将下一线段添加到曲线。必须使用 一个特殊的强制转换操作 static_pointer_cast 来转换智能指针,这等价于普通指针的 static_cast。智能 指针还能使用 dynamic_pointer_cast 和 const_pointer_cast。这些操作由 memory 头文件和 std 名称空间 中的函数模板定义。最后绘制曲线并返回。必须在 SketcherView.cpp 中给 Curve.h 添加#include。在 最终的视图类中要引用其他元素类,所以可以包含其他三个元素类的头文件。 当前元素存在,但不是曲线时,在调用 aDC 对象的 SetROP2()函数将绘图模式设置为 R2_NOTXORPEN 之后重新绘制旧元素,这会擦除旧的临时元素。不需要重置 m_pTempElement, 因为在 if 语句后要替代它包含的指针。 如果临时元素存在,但不是曲线,或者没有临时元素存在,就执行 if 语句后面的代码。在这两 种情况下,都需要创建当前类型的新元素,按正常方式给它指定颜色,并绘制它。我们创建新元素, 并把它作为参数传递给其 reset()成员,把它的地址存储在 m_pTempElement 中。在用参数替换它之 前,这会减少 m_pTempElement 包含的当前指针的引用计数。在本例中,以前元素如果存在,其引 用计数就减少到 0,所以销毁它。 如果 m_pTempElement 包含 nullptr,就用新元素指针替换它。 reset() 函数有第二个无参数的重载版本,如果当前指针的计数是 1,调用这个版本就会给该计数减去 1,并 在 shared_ptr 对象中用 nullptr 替换当前指针。代码会自动拉伸正在创建的形状,所以形状在光标移 动时附着在光标位置上。 使用指向新元素的智能指针,调用其 Draw()成员,让这个对象绘制自身的形状。 CClientDC 对 象的地址将作为参数传递。因为在基类 CElement 中已经把 Draw()成员函数定义为虚函数,所以无 论 m_pTempElement 指向什么类型的元素,都将自动选择这个函数。新元素通常将以 R2_NOTXORPEN 绘图模式绘制,因为这是第一次在白色背景上绘制该元素。 创建元素 把 CreateElement()函数作为 protected 成员添加到 CSketcherView 类的 Implementation 部分: class CSketcherView: public CView { // Rest of the class definition as before... 第 14 章 在窗口中绘图 609 // Implementation // Rest of the class definition as before... protected: CElement* CreateElement(void) const; // Create a new element on the heap // Rest of the class definition as before... }; 这时可以通过添加粗体显示的行直接修改类定义,也可以在 Class View 窗格中右击类名 CSketcherView,然后从上下文菜单中选择 Add | Add Function 菜单项,并通过显示的对话框添加函 数。函数的访问特性是 protected,因为它只能从视图类内部调用。 如果在类定义中手动添加这个声明,就需要在.cpp 文件中添加该函数的完整定义,如下所示: // Create an element of the current type CElement* CSketcherView::CreateElement(void) const { // Get a pointer to the document for this view CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); // Verify the pointer is good // Get the current element color COLORREF color = static_cast(pDoc->GetElementColor()); // Now select the element using the type stored in the document switch(pDoc->GetElementType()) { case ElementType::RECTANGLE: return new CRectangle(m_FirstPoint, m_SecondPoint, color); case ElementType::CIRCLE: return new CCircle(m_FirstPoint, m_SecondPoint, color); case ElementType::CURVE: return new CCurve(m_FirstPoint, m_SecondPoint, color); case ElementType::LINE: return new CLine(m_FirstPoint, m_SecondPoint, color); default: // Something’s gone wrong AfxMessageBox(_T(“Bad Element code”), MB_OK); AfxAbort(); return nullptr; } } 和前面看到的一样,首先调用 GetDocument()获取指向文档的指针。为了安全起见,使用 ASSERT_ VALID()宏确保返回一个有效的指针。在应用程序的调试版中,这个宏将调用对象的 AssertValid() 成员,它被指定为这个宏的参数。这将检查当前对象的有效性,如果指针为 NULL,或者这个对象 在某个方面有缺陷,那么将显示一条错误消息。在应用程序的发布版本中, ASSERT_ VALID()宏没 有任何作用。 在创建元素时, GetElementColor()函数把 ElementColor 枚举返回为一个颜色值,但不能使用 Visual C++ 2012 入门经典(第 6 版) 610 ElementColor 枚举,因为它的类型是错误的。但是,每个枚举的数值是一个 COLORVALUE 颜色值, 所以可以把该枚举转换为 COLORREF 类型,来获取颜色值。 switch 语句基于 GetElementType()返回 的类型,选择要创建的元素。 在类型不是 ElementType 枚举的情况下 (不太可能),应显示一个消息框, 并结束程序。在 SketcherView.cpp 中应给四个派生元素类的头文件添加#include 指令。 处理 WM_LBUTTONUP 消息 WM_LBUTTONUP 消息完成创建元素的过程。这种消息处理程序的工作是把创建的元素的最 终版本传递到文档对象,然后清理视图对象数据成员。可以在 CSketcherDoc 类中添加一个 STL 容 器对象来存储元素。还需要一个文档类函数,在需要添加元素时调用。我们还可以在某处从文档中 删除元素。此时使用 vector会比较快,但这里使用列表容器,因为后面要使用矢量容器来存储曲 线的点。给 CSketcherDoc 添加 protected 数据成员: std::list> m_Sketch; // A list containing the sketch sketch 元素在堆上创建,所以可以存储指向元素的智能指针,而不是存储元素本身。在 SketcherDoc.h 中给 memory 和 Element.h 头文件添加 #include 指令。销毁文档对象时,也会销毁 m_Sketch 和它包 含的智能指针指向的所有元素。 在 CSketcherDoc 类的 Implementation 部分把下面的函数定义添加为 public 成员: // Add a sketch element void AddElement(std::shared_ptr& pElement) { m_Sketch.push_back(pElement); } // Delete a sketch element void DeleteElement(std::shared_ptr& pElement) { m_Sketch.remove(pElement); } 第一个函数把传递为参数、 指向元素的智能指针添加到 m_Sketch 列表中。第二个函数从列表中 删除指针,这也会删除堆中的元素。下一章将实现删除 sketch 元素的 UI 机制。 现在可以实现 OnLButtonUp()处理程序了。在 SketcherView.cpp 的函数定义中添加如下代码: void CSketcherView::OnLButtonUp(UINT nFlags, CPoint point) { // Make sure there is an element if(m_pTempElement) { // Add the element pointer to the sketch GetDocument()->AddElement(m_pTempElement); InvalidateRect(&m_pTempElement->GetEnclosingRect()); m_pTempElement.reset(); // Reset the element pointer } } 在处理 m_pTempElement 之前,if 语句将验证它不为 nullptr。用户始终可以在不移动鼠标的情况 下按下和释放鼠标左键,这时不创建任何元素。只要有一个元素,指向该元素的指针就传递给文档 第 14 章 在窗口中绘图 611 对象函数,将它添加到草图中。最后, m_pTempElement 指针重置为 nullptr,等待下一次用户绘制 元素。 14.5 绘制草图 构成草图的所有元素都安全地存储在文档对象中。现在需要一种方式在视图中显示草图。 CSketcherView 中的 OnDraw()函数可以完成这个任务。显然,它需要访问文档对象拥有的草图数据, 理想情况下,它访问这些数据时,文档对象不需要把这些数据提供给外界,以进行修改。为此,一 种方式是文档对象使用 const 迭代器。草图的迭代器类型名称有点长,在 #include 指令的后面给 SketcherDoc.h 添加如下类型定义,可以减少输入工作: typedef std::list>::const_iterator SketchIterator; 现在可以在代码中使用 SketchIterator 为草图列表容器指定 const 迭代器的类型了。在 CSketcherDoc 类定义中添加如下函数,就可以让视图对象获得绘制草图所需的迭代器: // Provide a begin iterator for the sketch SketchIterator begin() const { return std::begin(m_Sketch); } // Provide an end iterator for the sketch SketchIterator end() const { return std::end(m_Sketch); } 这些迭代器应是 public,可以把它们放在 CSketcherDoc 的 Operations 部分。 const 迭代器可以递 增,也可以解除引用,但不能用于修改它指向的内容。使用这些函数可以实现 CSketcherView 的 OnDraw()成员: void CSketcherView::OnDraw(CDC* pDC) { CSketcherDoc* pDoc = GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // Draw the sketch for(auto iter = pDoc->begin() ; iter != pDoc->end() ; ++iter) { std::shared_ptr pElement(*iter); if(pDC->RectVisible(pElement->GetEnclosingRect())) // Element visible? pElement->Draw(pDC); // Yes, draw it. } } 文档对象的 begin()成员所返回的迭代器指向一个列表元素,它是指向草图元素的智能指针。必 须解除迭代器的引用,存储得到的智能指针,才能调用元素对象的函数。我们只希望元素在视图的 工作区中可见时绘制它。绘制在视图中不可见的元素只会浪费处理器的时间。如果传递为参数的矩 形的任意部分在工作区中, CDC 对象的 RectVisible()成员就返回 True,否则返回 False。在 if 语句中 调用这个函数,可确保只绘制可见的元素。把指向设备上下文的指针作为参数调用 Draw()成员,就 绘制了一个草图元素。 Visual C++ 2012 入门经典(第 6 版) 612 14.5.1 运行示例 确信已经存储了所有源文件以后,开始构建这个程序。如果在输入代码时没有出现错误,那么 代码的编译和链接将不会出现问题,从而可以执行这个程序。可以利用这个程序支持的 4 种颜色绘 制直线、圆、矩形和曲线。典型的窗口如图 14-17 所示。 现在对用户界面做一些实验。注意,可以来回移动窗口,在移动窗口时,把形状隐藏起来后, 如果希望显示它们,形状会自动绘制。但是,不是一切都正常。如果尝试在绘制形状时把光标拖动 到工作区以外,那么会出现一些奇怪的结果。这时在视图窗口之外失去了对鼠标的跟踪,这往往会 搅乱橡皮筋机制。怎么回事呢? 图 14-17 14.5.2 捕获鼠标消息 上述问题的原因在于 Windows 将鼠标消息发送到光标下方的窗口。 光标一离开应用程序视图窗 口的工作区, WM_MOUSEMOVE 消息就将发送到别的地方。使用 CSketcherView 类的一些继承成 员可以解决这个问题。 视图类从 CWnd 继承了函数 SetCapture(),通过调用这个函数,可以告诉 Windows,视图窗口 希望获取所有鼠标消息,这称为捕获鼠标,直到通过调用视图类的另一个继承函数 ReleaseCapture() 结束捕获为止。 通过修改 WM_LBUTTONDOWN 消息的处理程序,可以在一按下鼠标左键时就捕获鼠标: // Handler for left mouse button down message void CSketcherView::OnLButtonDown(UINT nFlags, CPoint point) { m_FirstPoint = point; // Record the cursor position SetCapture(); // Capture subsequent mouse messages } 现在,必须在 WM_LBUTTONUP 消息处理程序中调用函数 ReleaseCapture()。否则,只要您的 程序继续运行,其他程序就不能接收任何鼠标消息。当然,只有在已经捕获鼠标之后才能释放鼠标 第 14 章 在窗口中绘图 613 键。视图类继承的函数 GetCapture()将返回一个指向已经捕获鼠标的窗口的指针,这样就可以表明 是否已经捕获鼠标消息。只需要要在 WM_LBUTTONUP 的处理程序中添加下列代码: void CSketcherView::OnLButtonUp(UINT nFlags, CPoint point) { if(this == GetCapture()) ReleaseCapture(); // Stop capturing mouse messages // Make sure there is an element if(m_pTempElement) { GetDocument()->AddElement(m_pTempElement); m_pTempElement.reset(); // Reset the element pointer } } 如果函数 GetCapture()返回的指针等于指针 this,那么视图已经捕获了鼠标, 所以可以释放按钮。 应当做的最后一个修改是修改 WM_MOUSEMOVE 处理程序, 使它只处理已经由视图捕获的消 息。这时只需要进行一个小改动: void CSketcherView::OnMouseMove(UINT nFlags, CPoint point) { // Define a Device Context object for the view CClientDC aDC(this); // DC is for this view // Verify the left button is down and mouse messages are captured if((nFlags & MK_LBUTTON) && (this == GetCapture())) { // Code as before... } } 现在,只有按下鼠标左键,视图窗口已经捕获了鼠标,这个处理程序才处理鼠标消息。 如果利用这些添加的代码重新构建 Sketcher 程序,就将发现以前光标拖出工作区时产生的问题 不再出现了。 14.6 小结 本章详细介绍了如何编写鼠标的消息处理程序,以及如何在 Windows 程序中组织绘图操作。本 章还介绍了多态地使用形状类可以按照相同的方式在任何形状上操作,而与形状的实际类型无关。 14.7 练习题 1. 在 Sketcher 程序中为椭圆元素添加菜单项和工具栏按钮,给椭圆添加一个元素类型,并定义 一个类以支持绘制由椭圆封闭矩形对角上的两个点定义的椭圆。 2. 要支持椭圆的绘制,现在需要修改哪些函数?修改 Sketcher 程序,绘制椭圆。 3. 为了让第一个点定义椭圆的圆心,当前光标位置定义封闭矩形的一个角,必须修改练习题 (2) Visual C++ 2012 入门经典(第 6 版) 614 所得到的示例中的哪些函数?按照上述要求修改这个示例 (提示:请查阅 Help 中的 CPoint 类成员 )。 4. 在菜单中添加一个新的 Pen Style 菜单,允许指定实线、虚线、点线、点划线和一划两点线。 5. 要支持这个菜单的操作, 以及利用练习题 (4)中列出的这些线型来绘制元素, 需要修改 Sketcher 程序的哪些部分? 6. 实现对这个新菜单以及用任何线型绘制所有元素的支持。 14.8 本章主要内容 本章主要内容如表 14-9 所示。 表 14-9 主 题 概 念 客户端坐标系统 默认情况下,Windows 使用原点在工作区左上角的客户端坐标系统处理窗口的工作区。 x 轴的正方向从左到右,y 轴的正方向从上到下 在工作区中绘图 只能使用设备上下文在窗口的工作区中绘图 设备上下文 为了处理窗口的工作区,设备上下文提供了大量称为映射模式的逻辑坐标系统 映射模式 映射模式的默认原点位置在工作区的左上角。默认的映射模式是 MM_TEXT,它提供以 像素为单位的坐标。在这种模式中,x 轴的正方向从左到右,y 轴的正方向从上到下 在窗口中绘图 尽管平常可以绘制临时实体,但是在响应 WM_PAINT 消息时,程序始终应当在窗口的 工作区中绘制永久性内容。对应用程序文档的所有绘制都应当在视图类的 OnDraw()成 员函数中进行控制。在应用程序接收到 WM_PAINT 消息时,将调用这个函数 重新绘制窗口 调用视图类的 InvalidateRect()函数成员,可以标识希望重新绘制的那部分工作区。当下 一个 WM_PAINT 消息发送到应用程序时,Windows 将把作为参数传递的这个区域添加 到要重新绘制的整个区域 鼠标消息 Windows 向应用程序发送有关鼠标事件的标准消息。利用 Class Wizard 可以创建处理这 些消息的处理程序 捕获鼠标消息 通过在视图类中调用继承的 SetCapture()函数,可以将所有鼠标消息发送到应用程序。在 完成这一操作时,必须通过调用 ReleaseCapture()函数释放鼠标键。否则,其他应用程序 将不能接收鼠标消息 橡皮筋操作 在创建几何实体时,通过在处理鼠标移动的消息处理程序中绘制它们,可以实现橡皮筋 操作 选择绘图模式 利用 CDC 类的 SetROP2()成员可以设置绘图模式。选择正确的绘图模式将大大简化橡皮 筋操作 添加事件处理程序 通过 GUI 组件的 Properties 窗口也可以自动添加事件处理程序函数 转换智能指针的类型 使用 static_pointer_cast、dynamic_pointer_cast 和 const_pointer_cast.可以把一种智能指针 转换为另一种智能指针类型 存储和打印文档 本章要点 ● 序列化的工作方式 ● 如何使类的对象可序列化 ● CArchive 对象在序列化中的作用 ● 如何在自己的类中实现序列化 ● 如何在 Sketcher 应用程序中实现序列化 ● 打印如何使用 MFC ● 支持打印的视图类函数 ● CPrintInfo 对象包含的内容及其在打印过程中的应用 ● 如何在 Sketcher 应用程序中实现多页打印 利用目前在 Sketcher 程序中所完成的工作,可以创建一个相当全面、具有各种比例视图的文档, 但是因为现在没有办法保存文档,所以信息是临时的。本章将解决这个问题,分析如何在磁盘上存 储文档,以及如何把文档输出到打印机上。 17.1 了解序列化 在基于 MFC 的程序中,文档并非一个简单的实体——它可以是非常复杂的类对象。它通常包 含各种对象,而这些对象又都可能包含其他对象,这些对象仍然又都可能包含其他对象……这种结 构可能延续很多层次。 虽然希望能够把文档保存在文件中,但是将类对象写入文件意味着多少会有一些问题,因为类 对象不同于整数或字符串这样的基本数据项。基本数据项由已知数目的字节组成,所以将它们写入 文件只要求写入适当数目的字节。因此,如果已知一个 int 型的值写入了文件,那么在恢复它时, 只需要读取适当数目的字节即可。 将对象写入文件则是另外一回事。即使是连续地写入一个对象的所有数据成员,这也不足以恢 17 第 章 Visual C++ 2012 入门经典(第 6 版) 680 复原始对象。类对象包含函数成员以及数据成员,所有成员,包括数据成员和函数成员,都有访问 说明符;因此,要在外部文件中记录对象,写入文件的信息必须包含所有类结构的完整规范。读取 过程也必须非常智能,能够根据文件中的数据完整地组合成原始对象。MFC 支持一种称为序列化的 机制,它能够以最少的时间和精力,帮助实现类对象的输入和输出操作。 序列化的基本思想是任何可序列化的类都必须负责存储和检索自己。这意味着,要使类成为可 序列化的——就 Sketcher 应用程序而言,这包括 CElement 类和派生于它的形状类——它们就必须能 够将自己写入文件。这意味着要使一个类成为可序列化的,用于声明该类数据成员的所有类类型也 必须是可序列化的。 17.2 序列化文档 所有这些听起来虽然相当棘手,但是序列化文档的基本功能已经完全由 Application Wizard 内置 到应用程序中。File | Save、File | Save As 和 File | Open 菜单项的处理程序都假定您想对文档实现序 列化,并且已经包含了支持序列化的代码。下面将分析 CSketcherDoc 类的定义和实现中有关使用序 列化创建文档的部分内容。 17.2.1 文档类定义中的序列化 CSketcherDoc 类定义中支持文档对象序列化的代码在下列代码段中以粗体显示: class CSketcherDoc : public CDocument { protected: // create from serialization only CSketcherDoc(); DECLARE_DYNCREATE(CSketcherDoc) // Rest of the class... // Overrides public: virtual BOOL OnNewDocument(); virtual void Serialize(CArchive& ar); // Rest of the class... }; 其中有 3 个部分与序列化文档对象有关: (1) DECLARE_DYNCREATE()宏 (2) Serialize()成员函数 (3) 默认的类构造函数 DECLARE_DYNCREATE() 宏在序列化输入过程中,支持应用程序框架动态地创建 CSketcherDoc 类的对象。在类实现中,有一个互补宏 IMPLEMENT_DYNCREATE()与它配合使用。 这些宏只应用于 CObject 派生的类,但是我们很快将看到,它们并非唯一一对可以在这种上下文中 使用的宏。对于所有要序列化的类来说,CObject 都必须是直接或间接基类,因为它将添加支持序 列化操作的功能。这就是 CElement 类派生于 CObject 的原因。几乎所有 MFC 类都是派生于 CObject, 第 17 章 存储和打印文档 681 因此,它们都是可序列化的。 在 CSketcherDoc 类定义中还包括虚函数 Serialize()的声明。每个可序列化的类都必须包括这个 函数。调用它时将对 CSketcherDoc 类的数据成员执行输入和输出序列化操作。作为参数传递给这个 函数的 CArchive 类对象确定将要发生的操作是输入还是输出。在讨论对文档类实现序列化时,将详 细地探讨这个函数。 注意这个类显式地定义了一个默认的构造函数。这对于序列化操作来说也是必要的,因为从一 个文件读取数据时,框架将使用这个默认的构造函数组合一个对象,然后利用来自这个文件的数据 填充无参数构造函数生成的组合对象,以设置该对象数据成员的值。 17.2.2 文档类实现中的序列化 在 SketcherDoc.cpp 文件中,有两个部分与序列化有关。第一个部分是与 DECLARE_ DYNCREATE()宏互补的 IMPLEMENT_DYNCREATE()宏: // SketcherDoc.cpp : implementation of the CSketcherDoc class // #include "stdafx.h" // SHARED_HANDLERS can be defined in an ATL project implementing preview, thumbnail // and search filter handlers and allows sharing of document code with that project. #ifndef SHARED_HANDLERS #include "Sketcher.h" #include "PenDialog.h" #endif #include "SketcherDoc.h" #include #ifdef _DEBUG #define new DEBUG_NEW #endif // CSketcherDoc IMPLEMENT_DYNCREATE(CSketcherDoc, CDocument) // Message maps and the rest of the file... 这个宏把 CSketcherDoc 类的基类定义为 CDocument。为了正确地动态创建 CSketcherDoc 对象, 包括创建继承这个基类的成员,必须有这个宏。 1. Serialize()函数 在 CSketcherDoc 类实现中还包括 Serialize()函数的定义: 在 Visual C++的 MFC 参考中,层次结构图列出了不是从 CObject 派生的类。注意 CArchive 在这个列表中。 Visual C++ 2012 入门经典(第 6 版) 682 void CSketcherDoc::Serialize(CArchive& ar) { if (ar.IsStoring()) { // TODO: add storing code here } else { // TODO: add loading code here } } 该函数序列化这个类的数据成员。传递给这个函数的参数 ar 是对 CArchive 类对象的引用。如 果操作是在文件中存储数据成员,那么这个类对象的 IsStoring()成员将返回 TRUE,如果操作是从以 前存储的文档中读回数据成员,则返回 FALSE。 因为 Application Wizard 不知道您的文档中包含什么数据,所以就像注释所表明的那样,读写这 些信息的过程全依赖于您。为了了解这个过程,下面比较详细地分析一下 CArchive 类。 2. CArchive 类 CArchive 类是发动序列化机制的引擎。C++中的流操作在控制台程序中从键盘读取数据,然后 写入屏幕,CArchive 类则提供了基于 MFC 的流操作。CArchive 对象提供了一种机制,将您的对象 流出后放到文件中,或者重新把它们恢复为输入流,在这个过程中自动地重新构造类的对象。 CArchive 对象有一个与其相关联的 CFile 对象,它为二进制文件提供了磁盘输入/输出功能,并 且提供到物理文件的实际连接。在序列化过程中,CFile 对象处理文件输入和输出操作的所有具体问 题,CArchive 对象处理组织写入的对象数据或者根据读取的信息重新构造对象的逻辑问题。只有在 构造自己的 CArchive 对象时,才需要考虑关联对象 CFile 的细节问题。对于 Sketcher 程序中的文档, 框架已经进行了处理,并且把它构造的 CArchive 对象 ar 传递给 CSketcherDoc 中的 Serialize()函数。 在实现 Serialize()函数的序列化时,可以在添加到形状类中的所有 Serialize()函数中使用相同的对象。 CArchive 类重载了析取和插入运算符(<<和>>),它们对派生于 CObject 的类的对象以及大量基 本数据类型分别进行输入和输出操作。这些重载运算符处理下列对象类型和简单类型,见表 17-1。 表 17-1 类 型 定 义 bool 布尔值,真或假 float 标准单精度浮点值 double 标准双精度浮点值 BYTE 8 位无符号整数 char 8 位字符 wchar_t 16 位字符 short 16 位有符号整数 int 32 位有符号整数 LONG 和 long 32 位有符号整数 第 17 章 存储和打印文档 683 (续表) 类 型 定 义 LONGLONG 64 位有符号整数 ULONGLONG 64 位无符号整数 WORD 16 位无符号整数 DWORD 和 unsigned int 32 位无符号整数 CString 定义字符串的 CString 对象 SIZE 和 CSize 该对象把尺寸定义为 cx, cy 对 POINT 和 CPoint 该对象把点定义为 x, y 对 RECT 和 CRect 该对象利用矩形的左上角和右下角定义矩形 CObject* 指向 CObject 的指针 对于对象中的基本数据类型,使用插入和析取运算符序列化数据。在读写派生于 CObject 的可 序列化类的对象时,可以针对对象调用 Serialize()函数,也可以使用插入或析取运算符。无论选择使 用哪种方法,对于输入和输出都必须一致,不应当在输出对象时使用插入运算符,而在读回时使用 Serialize()函数,反之亦然。 如果在读取一个对象的类型但对它一无所知,如读取文档内形状列表中的指针时,那么只能使 用 Serialize()函数。因为这将使虚函数机制登场亮相,所以适合于所指对象类型的 Serialize()函数将 在运行时确定。 构造 CArchive 对象的目的是用于存储对象或者检索对象。如果对象用于输出,那么 CArchive 函数 IsStoring()将返回 TRUE,如果对象用于输入,则返回 FALSE。前面定义 CSketcherDoc 类的 Serialize()成员时,已经使用过这个函数。 CArchive 类还有许多其他成员函数,它们涉及序列化过程的详细技术,不过在您的程序中使用 序列化时,实际上不需要了解它们。 17.2.3 基于 CObject 的类的功能 对于从 MFC 类 CObject 派生的类来说,它们有 3 个层次的功能。在一个类中获得的功能层次取 决于在类定义中使用的 3 种不同的宏,见表 17-2。 表 17-2 宏 功 能 DECLARE_DYNAMIC() 支持运行时类信息 DECLARE_DYNCREATE() 支持运行时类信息和动态对象创建 DECLARE_SERIAL() 支持运行时类信息、动态对象创建和对象的序列化 其中每个宏都需要一个前缀为 IMPLEMENT_而非 DECLARE_的互补宏,这些互补宏存放在包 含类实现的文件中。由表 17-2 可知,这些宏提供的功能逐渐增多,由于第三个宏 DECLARE_SERIAL() 不仅包含前两个宏的功能,而且提供了另外的功能,所以本书将主要讨论它。这就是您在自己的类 中支持序列化时应当使用的宏。它要求在包含类实现的文件中添加宏 IMPLEMENT_SERIAL()。 Visual C++ 2012 入门经典(第 6 版) 684 您也许想知道,为什么文档类 CSketcherDoc 要使用 DECLARE_DYNCREATE()宏,而不使用 DECLARE_SERIAL()宏。DECLARE_DYNCREATE()宏在它出现的类中动态地创建该类的对象。 DECLARE_SERIAL()宏能够对类进行序列化,并且能够动态地创建类对象,所以它包含了 DECLARE_DYNCREATE()宏的功能。文档类 CSketcherDoc 不需要序列化,因为框架只需要组合这 个类对象,然后还原它的数据成员的值;但是,一个文档的数据成员必须是可序列化的,因为这是 用于存储和检索它们的过程。 将序列化添加到类中的宏 如果在基于 CObject 的类的定义中有 DECLARE_SERIAL()宏,那么可以访问 CObject 提供的序 列化支持。这包括特殊的 new 和 delete 操作符,它们把内存泄漏检测加入到调试模式中。在使用这 个宏时不需要做任何事情,因为它将自动完成操作。DECLARE_SERIAL()宏要求把类名指定为参数, 所以对 CElement 类进行序列化时,需要在类定义中添加下列行: DECLARE_SERIAL(CElement) 这个宏在类定义中的位置无关紧要,但是如果能够始终把它放在第一行,那么即使类定义包括 许多行代码,也能够知道它是否存在。 IMPLEMENT_SERIAL()宏存储在类的实现文件中,它要求指定 3 个参数。第一个参数是类的 名称,第二个参数是直接基类的名称,第三个参数是一个标识模式号的无符号 32 位整数,对于 Sketcher 程序来说就是版本号。如果写入对象和读取对象时使用的程序版本不同,这时类也可能不 同,那么可能出现一些问题,而这个模式号能够防止序列化过程出现的这些问题。 例如,可以添加下列行到 CElement 类的实现中: IMPLEMENT_SERIAL(CElement, CObject, 1001) 如果以后修改了类定义,那么需要把这个模式号修改成另外一个不同的模式号,如 1002。如果 这个程序试图从当前活动程序中读取利用不同模式号编写的数据,那么将抛出一个异常。这个宏最 好是放在.cpp 文件中#include 指令和初始注释之后的第一行。 当 CObject 是类的间接基类时,例如,在 CLine 类的情况中,那么要使序列化能够在顶级类中 操作,层次结构中的每个类都必须添加序列化宏。要使序列化能够在 CLine 类中操作,也必须在 CElement 中添加这些宏。 17.2.4 序列化的工作方式 图 17-1 以一种简化形式描述了对文档进行序列化的整个过程。 文档对象中的 Serialize()函数将为它的每个数据成员调用 Serialize()函数(或者使用重载的插入运 算符)。如果一个成员是类对象,那么这个对象的 Serialize()函数将对它的所有数据成员进行序列化, 直至最后将基本数据类型写入文件。由于 MFC 中的大部分类最终都派生于 CObject,因此它们都包 含序列化支持,因而对 MFC 类的对象几乎始终可以进行序列化处理。 此处不需要使用分号,因为这是一个宏,而不是 C++语句。 第 17 章 存储和打印文档 685 使用序列化进行文档输出 文档对象 对象成员 对象成员 基本数据类型 的成员 基本数据类型 的成员 文件 图 17-1 在类的 Serialize()成员函数以及应用程序文档对象中将要处理的数据在任何情况下都是数据成 员。有关的类和重新构造原始对象时需要的其他任何数据的结构都将由 CArchive 对象自动处理。 如果从 CObject 派生了多个层次的类,那么一个类中的 Serialize()函数都必须调用其直接基类的 Serialize()成员,以确保能够对直接基类数据成员进行序列化处理。注意序列化不支持多重继承,所 以在一个层次结构中定义的每个类只能有一个基类。 17.2.5 如何实现类的序列化 根据以前的讨论,下面总结了在一个类中添加序列化时需要采取的步骤: (1) 确保这个类是直接或间接派生于 CObject。 (2) 添加DECLARE_SERIAL()宏到类定义中(如果直接基类不是CObject或另一个标准MFC类, 还要在直接基类中添加这个宏)。 (3) 把 Serialize()函数声明为这个类的成员函数。 (4) 在包含类实现的文件中添加 IMPLEMENT_SERIAL()宏。 (5) 实现这个类的 Serialize()函数。 下面讨论如何针对 Sketcher 程序中的文档实现序列化。 17.3 应用序列化 要在 Sketcher 应用程序中实现序列化,必须在 CSketcherDoc 类中实现 Serialize()函数,以便这 个函数可以处理该类的所有数据成员。对于指定可能要包括在文档中的对象的每个类,都需要添加 序列化。开始在应用程序类中添加序列化之前,应当对 Sketcher 程序做一些小的修改,以记录用户 Visual C++ 2012 入门经典(第 6 版) 686 对草图文档所做的修改。虽然这并非完全必要,但是强烈建议这样做,因为这能够防止程序在没有 保存修改的情况下关闭文档。 17.3.1 记录文档修改 已经有一种机制用于记录文档的修改;它使用 CSketcherDoc 的一个继承成员 SetModifiedFlag()。 每次修改文档时都调用这个函数,可以把文档已被修改的事实记录在文档类对象的数据成员中。如 果试图在没有保存已修改文档的情况下退出应用程序,就会自动显示一个提示消息。 SetModifiedFlag()函数的参数是一个 BOOL 型的值,默认值是 TRUE。如果偶尔要说明文档未被修 改,那么可以用参数 FALSE 调用这个函数。 修改文档对象中草图的情况只有 4 种: ● 调用 CSketcherDoc 的成员 AddElement()添加新元素。 ● 调用 CSketcherDoc 的成员 DeleteElement()删除元素。 ● 调用文档对象的 SendToBack()函数 ● 移动元素。 这 4 种情况都容易处理。需要做的仅仅是针对这些操作中所涉及的每个函数添加对 SetModified- Flag()的调用。AddElement()的定义出现在 CSketcherDoc 类定义中。可以把这个定义扩展为: void AddElement(std::shared_ptr& pElement) // Add an element to the list { m_Sketch.push_back(pElement); UpdateAllViews(nullptr, 0, pElement.get()); // Tell all the views SetModifiedFlag(); // Set the modified flag } DeleteElement()函数的定义也在 CSketcherDoc 定义中。应当在这个定义中添加一行如下所示的 代码: void DeleteElement(std::shared_ptr& pElement) { m_Sketch.remove(pElement); UpdateAllViews(nullptr, 0, pElement.get()); // Tell all the views SetModifiedFlag(); // Set the modified flag } SendToBack()函数也需要添加这行代码: void SendToBack(std::shared_ptr& pElement) { if(pElement) { m_Sketch.remove(pElement); // Remove the element from the list m_Sketch.push_back(pElement); // Put a copy at the end of the list SetModifiedFlag(); // Set the modified flag } } 在视图对象中,移动元素的操作出现在由 WM_MOUSEMOVE 消息处理程序调用的 MoveElement() 成员中,但是只有在按下鼠标左按钮时才能修改文档。如果右击鼠标,那么元素将返回其原来的位 第 17 章 存储和打印文档 687 置,所以只需要在 OnLButtonDown()函数中添加对文档的 SetModifiedFlag()函数的调用,如下所示: void CSketcherView::OnLButtonDown(UINT nFlags, CPoint point) { CClientDC aDC(this); // Create a device context OnPrepareDC(&aDC); // Get origin adjusted aDC.DPtoLP(&point); // convert point to Logical CSketcherDoc* pDoc = GetDocument(); // Get a document pointer if(m_MoveMode) { // In moving mode, so drop the element m_MoveMode = false; // Kill move mode auto pElement(m_pSelected); // Store selected address m_pSelected = nullptr; // De-select the element pDoc->UpdateAllViews(nullptr, 0, pElement.get());// Redraw all the views pDoc->SetModifiedFlag(); // Set the modified flag } // Rest of the function as before... } 调用视图类的继承成员 GetDocument()可以访问指向文档对象的指针,然后使用这个指针调用 SetModifiedFlag()函数。 在文档中可以进行修改的所有地方现在就介绍完毕。文档对象还存储了元素类型、元素颜色和 线宽,所以也需要跟踪它们的修改。下面更新 OnColorBlack(): void CSketcherDoc::OnColorBlack() { m_Color = ElementColor::BLACK; // Set the drawing color to black SetModifiedFlag(); // Set the modified flag } 给其他颜色和元素类型的处理程序添加相同的语句。设置线宽的处理程序需要修改: void CSketcherDoc::OnPenWidth() { CPenDialog aDlg; // Create a local dialog object aDlg.m_PenWidth = m_PenWidth; // Set pen width as that in the document if(aDlg.DoModal() == IDOK) // Display the dialog as modal { m_PenWidth = aDlg.m_PenWidth; // When closed with OK, get the pen width SetModifiedFlag(); // Set the modified flag } } 如果在构建和运行 Sketcher 程序时修改文档或者添加元素,那么在退出这个程序时,将出现保 存文档的提示。当然,除了可以清除修改标志以及把空文件保存到磁盘中以外,File | Save 菜单选项现 在还不能进行其他操作。为了可以正确地把文档连续写入磁盘,必须实现序列化,下面对此进行介绍。 17.3.2 序列化文档 第一步是针对 CSketcherDoc 类实现 Serialize()函数。在这个函数内,为了对 CSketcherDoc 的数 据成员进行序列化,必须添加代码。在这个类中已经声明的数据成员如下所示: Visual C++ 2012 入门经典(第 6 版) 688 protected: ElementType m_Element; // Current element type ElementColor m_Color; // Current drawing color std::list> m_Sketch; // A list containing the sketch int m_PenWidth; // Current pen width CSize m_DocSize; // Document size 这些数据成员必须可序列化,以允许 CSketcherDoc 对象可反序列化。需要做的仅仅是在这个类的 Serialize()成员中插入存储和检索这 5 个数据成员的语句。但这还是有一个问题。对象 list>不是可序列化的,因为其模板不是从 CObject 派生的。事实上,STL 容器都不是可序 列化的,因此必须自己处理 STL 容器的序列化。 但问题还有希望解决。如果能够序列化容器中的指针指向的对象,那么当把它读回来时,就能 够重新构建容器。 下面的代码可以实现文档对象的序列化: void CSketcherDoc::Serialize(CArchive& ar) { if (ar.IsStoring()) { ar << static_cast(m_Color) // Store the current color << static_cast(m_Element) // the element type as an integer << m_PenWidth // and the current pen width << m_DocSize; // and the current document size ar << m_Sketch.size(); // Store the number of elements in the list // Now store the elements from the list for(auto& pElement : m_Sketch) ar << pElement.get(); // Store the element pointer } else { COLORREF color(0); int elementType(0); ar >> color // Retrieve the current color >> elementType // the element type as an integer >> m_PenWidth // and the current pen width >> m_DocSize; // and the current document size m_Color = static_cast(color); m_Element = static_cast(elementType); // Now retrieve all the elements and store in the list size_t elementCount(0); // Count of number of elements ar >> elementCount; // retrieve the element count CElement* pElement; for(size_t i = 0 ; i < elementCount ; ++i) { ar >> pElement; MFC 定义了可序列化的容器类,如 CList。但是,如果在 Sketcher 中使用这些类, 就 无法了解如何使一个类可序列化了。 第 17 章 存储和打印文档 689 m_Sketch.push_back(std::shared_ptr(pElement)); } } } 对于其中的 4 个数据成员,只使用了在 CArchive 类中重载的析取和插入运算符。这不适用于数 据成员 m_Color,因为 ElementColor 类型是不可序列化的。但可以把它的类型转换为可序列化的 COLORREF,因为类型 COLORREF 和类型 long 相同。m_Element 成员的类型是 ElementType,序 列化过程不直接处理它。但是,可以将它强制转换成一个整数以便序列化,然后在将此值强制转换 回 ElementType 之前,将它反序列化为一个整数。 对于元素列表 m_Sketch,首先将元素数量的计数存储到存档的列表中,因为读回元素时将需要 它。然后在 for 循环中将 shared_ptr 对象包含的元素指针从列表写到存档中。此序列化机制将识别出 需要被指向的对象来重新构建文档,而且负责将它们写到存档中。 if 的 else 子句处理从档案中读回文档对象。使用析取运算符从存档中检索前 4 个成员,它们的 顺序与写到存档中的顺序相同。元素类型和颜色读到本地整型变量 elementType 和 color 中,然后按 照正确的类型存储到 m_Element 和 m_Color 成员中。 接下来读取存档中记录的元素数量,并将它存储在本地 elementCount 中。最后,使用 elementCount 来控制 for 循环,此 for 循环从档案读回元素,并将它们存储到列表中。注意,不需要针对元素最初是 在堆上创建的这样一个事实做任何特殊的工作。序列化机制自动负责还原堆上的元素。我们只需把每 个对象的指针传递给 shared_ptr构造函数。 如果您在反序列化一个对象时,不清楚 list>对象来自哪里,则会由序列 化过程使用 CSketcherDoc 类的默认构造函数来创建。这就是基本文档对象及其未初始化数据成员的 创建方式,很神奇吧。 序列化文档类数据成员时就需要做这么多,但在序列化列表中的元素时,为了存储和检索元素 本身,将调用元素类的 Serialize()函数,所以还需要针对这些类实现序列化。 17.3.3 序列化元素类 所有形状类都是可序列化的,因为它们都派生于其基类 CElement,而 CElement 又派生于 CObject。把 CObject 指定为 CElement 的基类的原因仅仅是为了获得对序列化的支持。确保实际上 为每个形状类定义了默认构造函数。反序列化过程要求定义此构造函数。 现在可以为每个形状类添加对序列化的支持,这要在类定义和实现中添加适当的宏,然后给每 个类的 Serialize()函数成员添加代码,以序列化该函数的数据成员。可以首先从基类 CElement 开始, 其中需要对类定义做如下修改: class CElement: public CObject { DECLARE_SERIAL(CElement) protected: CPoint m_StartPoint; // Element position int m_PenWidth; // Pen width COLORREF m_Color; // Color of an element CRect m_EnclosingRect; // Rectangle enclosing an element public: Visual C++ 2012 入门经典(第 6 版) 690 virtual ~CElement(); virtual void Draw(CDC* pDC, std::shared_ptr pElement=nullptr) {} virtual void Move(const CSize& aSize){} // Move an element virtual void Serialize(CArchive& ar) override; // Serialize object // Get the element enclosing rectangle const CRect GetEnclosingRect() const { return m_EnclosingRect; } protected: // Constructors protected so they cannot be called outside the class CElement(); CElement(const CPoint& start, COLORREF color, int penWidth = 1); // Create a pen void CreatePen(CPen& aPen, std::shared_ptr pElement) { if(!aPen.CreatePen(PS_SOLID, m_PenWidth, (this == pElement.get()) ? SELECT_COLOR : m_Color)) { // Pen creation failed AfxMessageBox(_T(“Pen creation failed.”), MB_OK); AfxAbort(); } } }; 其中添加了 DECLARE_SERIAL()宏以及虚函数 Serialize()的声明。Application Wizard 已经创建 了一个默认的构造函数,在这个类中把它修改成了 protected 类型,只要它显式地出现在类定义中, 它的访问规范是什么就无关紧要。它可以是 public、protected 或 private 类型,但是序列化仍然有效。 如果忘记在一个类中包括默认构造函数,那么在编译 IMPLEMENT_SERIAL()宏时,将出现一条错 误消息。 应当为每个派生类 CLine、CRectangle、CCircle、CCurve 和 CText 都添加 DECLARE_SERIAL() 宏,其参数为相关类名。另外,还应当把 Serialize()函数的重载声明添加为每个类的 public 成员。 在文件 Elements.cpp 中,必须在开始处添加下列宏: IMPLEMENT_SERIAL(CElement, CObject, VERSION_NUMBER) 在文件 Element.h 的其他静态常量的后面,添加下列行,可以定义静态常量 VERSION_NUMBER: static const UINT VERSION_NUMBER = 1001; // Version number for serialization 然后在为其他形状类的.cpp 文件中添加这个宏时,就可以使用该常量。例如对于 CLine 类,应 当添加下列行: IMPLEMENT_SERIAL(CLine, CElement, VERSION_NUMBER) 对于其他形状类也可以采用类似的方法。在修改与文档有关的类时,只需要在文件 Element.h 中 修改 VERSION_NUMBER 的定义,这个新版本号在所有的 Serialize()函数中都将适用。 第 17 章 存储和打印文档 691 形状类中的 Serialize()函数 现在可以针对每个形状类实现 Serialize()成员函数。首先从 CElement 类开始,在 Elements.cpp 中添加如下定义: void CElement::Serialize(CArchive& ar) { CObject::Serialize(ar); // Call the base class function if (ar.IsStoring()) { // Writing to the file ar << m_StartPoint // Element position << m_PenWidth // The pen width << m_Color // The element color << m_EnclosingRect; // The enclosing rectangle } else { // Reading from the file ar >> m_StartPoint // Element position >> m_PenWidth // The pen width >> m_Color // The element color >> m_EnclosingRect; // The enclosing rectangle } } 这个函数的形式和 CSketcherDoc 类中提供的函数一样。重载的析取和插入运算符支持在 CElement 中定义的所有数据成员,所以所有操作都是由这些运算符完成的。注意必须调用 CObject 类的 Serialize()成员,以确保可以序列化继承的数据成员。 对于 CLine 类,可以把这个函数编写为: void CLine::Serialize(CArchive& ar) { CElement::Serialize(ar); // Call the base class function if (ar.IsStoring()) { // Writing to the file ar << m_EndPoint; // The end point } else { // Reading from the file ar >> m_EndPoint; // The end point } } CArchive 对象 ar 的析取和插入运算符支持所有数据成员。其中调用基类 CElement 的 Serialize() 成员序列化其数据成员,而这将调用 CObject 的 Serialize()成员。可以看到序列化过程在这个类层次 结构中是如何层叠的。 CRectangle 类的 Serialize()函数成员比较简单: void CRectangle::Serialize(CArchive& ar) { CElement::Serialize(ar); // Call the base class function if (ar.IsStoring()) { // Writing to the file Visual C++ 2012 入门经典(第 6 版) 692 ar << m_BottomRight; // Bottom-right point for the rectangle } else { // Reading from the file ar >> m_BottomRight; } } 这只调用直接基类函数 Serialize(),序列化矩形的右下角点。 CCircle 类的 Serialize()函数也与 CRectangle 类相同: void CCircle::Serialize(CArchive& ar) { CElement::Serialize(ar); // Call the base class function if (ar.IsStoring()) { // Writing to the file ar << m_BottomRight; // Bottom-right point for the circle } else { // Reading from the file ar >> m_BottomRight; } } 对于 CCurce 类,要做的工作就比较多。CCurve 类使用 vector容器存储定义的点,因 为这不能直接序列化,所以您必须自己负责处理。将文档序列化之后,情况就不太难了。可以如下 面这样编写 Serialize()函数的代码: void CCurve::Serialize(CArchive& ar) { CElement::Serialize(ar); // Call the base class function // Serialize the vector of points if (ar.IsStoring()) { ar << m_Points.size(); // Store the point count // Now store the points for(size_t i = 0 ; i< m_Points.size() ; ++i) ar << m_Points[i]; } else { size_t nPoints(0); // Stores number of points ar >> nPoints; // Retrieve the number of points // Now retrieve the points CPoint point; for(size_t i = 0 ; i < nPoints ; ++i) { ar >> point; m_Points.push_back(point); } } } 首先调用基类 Serialize()函数,处理继承的类成员的序列化。存储矢量内容所用的技术与序列化 第 17 章 存储和打印文档 693 文档列表所用的技术基本相同。首先将容器中的元素数量写入存档,然后再将元素本身写入存档。 CPoint 类是可序列化的,所以它本身可以负责处理。读取这些点所采用的技术同样很简单。只需要 在 for 循环中将从存档读取的每个对象存储到矢量 m_Points 中。序列化过程使用 CCurve 类无参数 的构造函数来创建基类对象,因此 vector成员在此过程中创建。 需要将 Serialize()函数的实现添加到最后一个类 CText 中: void CText::Serialize(CArchive& ar) { CElement::Serialize(ar); // Call the base class function if (ar.IsStoring()) { ar << m_String; // Store the text string } else { ar >> m_String; // Retrieve the text string } } 在调用了基类函数以后,利用 ar 的插入和析取运算符序列化 m_String 数据成员。尽管 CString 类不是派生于 CObject,但是具有这些重载运算符的 CArchive 仍然完全支持它。 17.4 练习序列化 这就是在 Sketcher 程序中实现文档的存储和检索需要做的所有工作!文件菜单中的 Save 和Open 菜单项现在已经完全能够使用,而不用再添加任何代码。如果在加入本章讨论的修改以后构建和运 行 Sketcher 程序,那么可以保存和还原文件,并且在试图关闭已修改文档或者从该程序中退出时, 将自动提示您保存文档,如图 17-2 所示。 图 17-2 Visual C++ 2012 入门经典(第 6 版) 694 出现提示的原因在于更新文档时添加的 SetModifiedFlag()调用。假定以前没有保存文件,如果 单击图 17-2 所示的屏幕中的 Yes 按钮,将出现如图 17-3 所示的 File | Save As 对话框。 图 17-3 这是 Windows 中该菜单项的标准对话框。此对话框功能全面,由框架提供的代码支持。这个文 档的文件名已经根据第一次打开该文档时分配的名称生成,文件扩展名将自动定义为.ske。Sketcher 应用程序现在完全支持对文档的文件操作。 17.5 打印文档 现在分析如何打印草图。借助于 Application Wizard 和框架,在 Sketcher 程序中已经实现了基本 的打印能力。File | Print、File | Print Setup 和 File | Print Preview 菜单项都可以使用。选择 File | Print Preview 菜单项后将出现一个窗口,在一个页面上显示当前的 Sketcher 文档,如图 17-4 所示。 图 17-4 当前文档中的所有内容都按照当前视图比例显示在一页纸上。如果文档的范围超出了这页纸的 边界,那么将不打印超出的部分。如果选择 Print 按钮,则这个页面将发送到打印机。 第 17 章 存储和打印文档 695 作为一种免费取得的基本能力,这非常令人难忘,但是它还不足以满足我们的要求。在 Sketcher 程序中,典型的文档很可能不能完全排在一个页面上,因此要么调整文档的比例,要么使用一种更 方便的方法,在需要的多个页面上打印整个文档。可以添加自己的打印处理代码,扩展框架提供的 功能,但是在实现这之前,首先需要了解打印在 MFC 中是如何实现的。 打印过程 打印文档是由当前视图控制的。这个过程肯定会有点麻烦,因为打印本来就是一个麻烦事,我 们很可能需要在视图类中实现大量自己的继承函数。图 17-5 显示了这个过程的逻辑原理和有关的函 数。此图也说明了框架如何控制事件的顺序,打印文档如何涉及调用视图类的 5 个继承成员,可能 还需要重写这些成员。该图左边显示的 CDC 成员函数与打印机设备驱动程序进行通信,框架将自 动调用它们。 视图对象成员 当有多个页面时循环 框 架 · 计算页面数 · 调用 DoPreparePrinting() · 分配 GDI 资源 · 修改视口原点 · 设置 DC 属性 · 打印页眉/页脚 · 打印当前页面 · 取消分配 GDI 资源 图 17-5 在打印操作期间,当前视图中每个函数的典型作用由它们旁边的注释说明。调用它们的顺序由 箭头上的数字标明。实际上,不必实现所有这些函数,而只需要实现满足特定打印要求的那些函数。 通常,我们至少需要实现自己的 OnPreparePrinting()、OnPrepareDC()和 OnPrint()函数。本章稍后将 介绍一个示例,说明如何在 Sketcher 程序的上下文中实现这些函数。 输出数据到打印机与输出数据到显示器的方式是一样的—— 通过设备上下文。用于输出文本或 图形的 GDI 调用与设备无关,所以它们对打印机的应用和对显示器的应用一样,唯一的区别在于 CDC 对象应用的物理输出设备。 在图 17-5 中,CDC 函数与打印机的设备驱动程序进行通信。如果要打印的文档需要多个的打印 Visual C++ 2012 入门经典(第 6 版) 696 页,那么这个过程将为每个连续的新页面循环调用 OnPrepareDC()函数,循环次数由 EndPage()函数 确定。指向 CPrintInfo 类型对象的指针将作为参数传递给打印过程中涉及的视图类中的所有函数。 这种对象将在管理打印过程的所有函数之间提供链接,所以下面将详细地分析 CPrintInfo 类。 CPrintInfo 类 因为 CPrintInfo 对象随时存储有关正在执行的打印工作及其状态细节的信息,所以它在打印过 程中具有重要的作用。它还提供了访问和操作这些数据的函数。利用这个对象,可以在打印期间把 信息从一个视图函数传递到另一个视图函数,而且可以在框架和视图函数之间传递信息。 每当选择 File | Print 或 File | Print Preview 菜单项时,即创建 CPrintInfo 类的对象。由当前视图中 与打印过程有关的每个函数使用过以后,将在打印操作结束时自动删除它。 CPrintInfo 的所有数据成员都是 public。在打印草图时我们感兴趣的数据成员如表 17-3 所示。 表 17-3 数 据 成 员 用 途 m_pPD 该指针指向显示打印对话框的对象 CPrintDialog m_bDirect 如果打印操作将绕过打印对话框,框架将把这个成员设置为 TRUE;否则为 FALSE m_bPreview 这是一个 BOOL 型成员,如果选择了 File | Print Preview 菜单项,那么它的值是 TRUE; 否则为 FALSE m_bContinuePrinting 这是一个 BOOL 型成员。如果把它设置为 TRUE,那么框架将继续图 17-5 所示的打印循 环。如果把它设置为 FALSE,则结束打印循环。只有在没有把打印操作的页记数传递给 CPrintInfo 对象(使用 SetMaxPage()成员函数)时,才需要设置这个变量。在完成打印操作 以后,需要把这个变量设置为 FALSE m_nCurPage 这是一个 UINT 型的值,它存储当前页的页码。页面通常从 1 开始编号 m_nNumPreviewPages 这是一个 UINT 型的值,它指定在打印预览窗口中显示的页面数量。它的值可以是 1 或 2 m_lpUserData 这是一个 LPVOID 型成员,它存储指向所创建对象的指针。这样就可以创建存储有关打 印操作的其他信息的对象,并且可以把它和 CPrintInfo 对象关联起来 m_rectDraw 这个 CRect 对象以逻辑坐标定义页面的可用区域 m_strPageDesc 这个 CString 对象包含格式字符串,框架使用该字符串在打印预览期间显示页码 CPrintInfo 对象具有如表 17-4 所示的 public 成员函数。 表 17-4 成 员 函 数 说 明 SetMinPage(UINT nMinPage) 它的参数指定文档第一页的页码。它不返回任何值 SetMaxPage(UINT nMaxPage) 它的参数指定文档最后一页的页码。它不返回任何值 GetMinPage() const 把文档第一页的页码作为 UINT 型值返回 GetMaxPage() const 把文档最后一页的页码作为 UINT 型值返回 GetFromPage() const 把要打印的文档第一页的页码作为 UINT 型值返回。这个值通过打印对话框 设置 GetToPage() const 把要打印的文档最后一页的页码作为 UINT 型值返回。这个值通过打印对话 框设置 在打印一个包含几页的文档时,需要明白这个文档占用多少打印页,并且需要把这个信息存储 在 CPrintInfo 对象中,以便提供给框架。利用当前视图中您自己的 OnPreparePrinting()成员可以完成 第 17 章 存储和打印文档 697 这个操作。 页码存储为 UINT 类型。要设置文档中第一页的页码,需要调用 CPrintInfo 对象中的成员函数 SetMinPage(),它把这个页码作为一个参数。它不返回任何值。要设置文档中最后一页的页码,需 要调用函数 SetMaxPage()。如果以后想检索这些值,可以调用 CPrintInfo 对象中的成员函数 GetMinPage()和 GetMaxPage()。 提供的页码存储在由 CPrintInfo 对象的 m_pPD 成员所指的 CPrintDialog 对象中,从菜单中选择 File | Print 菜单项时,这些页码将显示在弹出的对话框中。然后用户可以指定要打印的第一页和最后 一页的页码,通过调用 CPrintInfo 对象的成员 GetFromPage()和 GetToPage()可以检索它们。在这两 种情况下,返回的值都是 UINT 类型。对话框将自动验证要打印的第一页和最后一页的页码是否在 指定文档的最小和最大页码时提供的范围内。 前面讨论了在管理打印时可以在视图类中实现的函数,其中大部分工作是由框架来完成的。另 外,还讨论了通过 CPrintInfo 对象可以将哪些信息传递到与打印有关的函数中。如果能够为 Sketcher 文档实现基本的多页打印能力,就可以更清楚地了解打印的详细技巧。 17.6 实现多页打印 在 Sketcher 程序中把映射模式设置为 MM_LOENGLISH,形状和视图范围的度量单位将是 0.01 英寸。当然,由于尺寸单位是一个固定的物理度量,因此在理想情况下,我们希望以对象的实际尺寸 打印它们。 当将文档尺寸指定为 3 000 单位×3 000 单位时,可以创建一个 30 平方英寸的文档,如果完全展 开,这需要很多张纸。与打印典型的文本文档相比,在打印草图时需要多花些精力来计算页面数量, 因为在大多数情况下,打印完整的草图文档需要二维数组的页面。 为了避免使问题过于复杂化,假设打印用纸为正规纸(A4 或者 8.5 英寸×11 英寸),方向为纵向 (这意味着长边是垂直的)。对于这两种纸型,都将在 7.5 英寸×10 英寸的纸张中心部分打印这个文 档。有了这些假设,就不必担心实际的纸张大小;只需要把这个文档切成 750 单位×1000 单位的块, 单位是 0.01 英寸。当一个文档大于一页时,可以分割这个文档,如图 17-6 中的示例所示。 4 个宽度 7.5 英寸 2 个高度 10 英寸 第 1 页 第 2页 第 3 页 第 4页 第 5页 第 6 页 第 7 页 第 8 页 图 17-6 Visual C++ 2012 入门经典(第 6 版) 698 可以看到,我们是按行给页面编号的,所以在这种情况下,第 1~4 页在第一行,第 5~8 页在第 二行。占用最大尺寸 30 英寸×30 英寸的草图将打印在 12 页上。 17.6.1 获取文档的总尺寸 要知道一个特定的文档占用多少页,需要知道草图有多大,因此我们希望使用矩形来包围文档 中的所有内容。通过在文档类 CSketcherDoc 中添加函数 GetDocExtent(),可以很容易做到这一点。 在 CSketcherDoc 的 public 接口中添加下列声明: CRect GetDocExtent() const; // Get the bounding rectangle for the whole document 这个函数的实现也没有问题。它的实现代码是: // Get the rectangle enclosing the entire document CRect CSketcherDoc::GetDocExtent()const { if(m_Sketch.empty()) // Check for empty sketch return CRect(0,0,1,1); CRect docExtent(m_Sketch.front()->GetEnclosingRect()); // Initial doc extent for(auto& pElement : m_Sketch) docExtent.UnionRect(docExtent, pElement->GetEnclosingRect()); docExtent.NormalizeRect(); return docExtent; } 可以在 SketcherDoc.cpp 文件中添加这个函数定义。 如果草图为空,就返回一个非常小的 CRect 对象。文档范围的最初尺寸是封闭列表中第一个元 素的矩形。接着,此过程循环访问文档中的每个元素,获取每个元素的边界矩形,并将它与 docExtent 合并。CRect 类的成员 UnionRect()包含两个作为参数传递的矩形,它将计算最小的矩形,然后把这 个值放在调用该函数的 CRect 对象中。因此,DocExtent 的大小将不断增加,直到把所有元素都包 含在内部。 17.6.2 存储打印数据 应用程序框架将调用视图类中的 OnPreparePrinting()函数,为文档初始化打印过程。要求进行基 本初始化的目的是为显示的打印对话框提供有关文档中页面数量的信息。也要把有关文档所需页面 的信息存储起来,以便以后在打印过程中涉及的其他视图函数中使用它们。这也是在视图类的 OnPreparePrinting()函数中完成的,要把这些信息存储在为此目的而定义的一个类对象中,然后把指 向这个对象的指针存储在框架将使用的 CPrintInfo 对象中。该方法主要是为了说明这种机制的工作 方式;在大部分情况下,比较容易的方法是完全把这些数据存储在视图对象中。 我们需要存储文档宽度上的页数 m_nWidths,以及文档长度上页面的行数量 m_nLengths。还需 要把包围文档数据的矩形的左上角作为 CPoint 对象 m_DocRefPoint 存储起来,因为在根据一个页的 页码计算其打印位置时,将使用到左上角这个位置。可以把文档的文件名存储在 CString 对象 m_DocTitle 中,这样就可以把它作为标题添加到每一页上。记录页面内可打印区域的大小时, m_DocTitle 也很有用。包含这些要求的类的定义是: #pragma once 第 17 章 存储和打印文档 699 class CPrintData { public: UINT printWidth; // Printable page width - units 0.01 inches UINT printLength; // Printable page length - units 0.01 inches UINT m_nWidths; // Page count for the width of the document UINT m_nLengths; // Page count for the length of the document CPoint m_DocRefPoint; // Top-left corner of the document contents CString m_DocTitle; // The name of the document // Constructor CPrintData(): printWidth(750), // 7.5 inches printLength(1000) // 10 inches {} }; 构造函数设置与 A4 页对应的可打印区域的默认值,其四个边的页边距为半英寸。可以根据自 己的环境更改此设置,当然,也可以在 CPrintData 对象中以编程方式更改这些值。 在 Solution Explorer 窗格中右击 Header Files 文件夹,然后从弹出式菜单中选择 Add | New Item 菜单项,就可以向项目中添加一个名称为 PrintData.h 的新头文件。然后可以在该新文件中输入这个 类定义。我们不需要这个类的实现文件。由于只在短时间内使用这个类的对象,因此不必把 CObject 作为它的基类,或者考虑其他复杂的方法。 打印过程开始于对视图类成员 OnPreparePrinting()的调用,所以下面分析如何实现它。 17.6.3 准备打印 Application Wizard 在开始时就在 CSketcherView 中添加 OnPreparePrinting()、OnBeginPrinting() 和 OnEndPrinting()函数。为OnPreparePrinting()函数提供的基本代码将在return语句中调用DoPreparePrinting() 函数: BOOL CSketcherView::OnPreparePrinting(CPrintInfo* pInfo) { // default preparation return DoPreparePrinting(pInfo); } DoPreparePrinting()函数将使用在 CPrintInfo 对象中定义的有关打印页数的信息显示 Print 对话 框。只要有可能,就应当在这个调用出现之前计算要打印的页数,并把它存储在 CPrintInfo 对象中。 当然在许多情况中,在执行该操作之前,可能需要从设备上下文获取有关打印机的信息。 例如,在打 印页数受所用字体大小影响的文档时,在调用 OnPreparePrinting()函数前不可能获得页数。在这种情 况下,可以在 OnBeginPrinting()成员中计算页数,这个函数将把指向设备上下文的指针作为参数。 在调用 OnPreparePrinting()函数之后,框架将调用 OnBeginPrinting()函数,这样就可以使用在 Print 对话框中输入的信息。这意味着,也可以考虑用户在 Print 对话框中选择的纸型。 假设这个纸型大到足以包含绘制文档数据时使用的 7.5 英寸×10 英寸区域,所以可以在 On- PreparePrinting()函数中计算页数。其代码是: BOOL CSketcherView::OnPreparePrinting(CPrintInfo* pInfo) { Visual C++ 2012 入门经典(第 6 版) 700 CPrintData* printData(new CPrintData); // Create a print data object CSketcherDoc* pDoc = GetDocument(); // Get a document pointer CRect docExtent = pDoc->GetDocExtent(); // Get the whole document area printData->m_DocRefPoint = docExtent.TopLeft(); // Save document reference point printData->m_DocTitle = pDoc->GetTitle(); // Save the document filename // Calculate how many printed page widths are required // to accommodate the width of the document printData->m_nWidths = static_cast(ceil( static_cast(docExtent.Width())/printData->printWidth)); // Calculate how many printed page lengths are required // to accommodate the document length printData->m_nLengths = static_cast( ceil(static_cast(docExtent.Height())/printData->printLength)); // Set the first page number as 1 and // set the last page number as the total number of pages pInfo->SetMinPage(1); pInfo->SetMaxPage(printData->m_nWidths*printData->m_nLengths); pInfo->m_lpUserData = printData; // Store address of PrintData object return DoPreparePrinting(pInfo); } 首先在堆上创建 CPrintData 对象,并把它的地址本地存储在指针中。获得了指向该文档的指针 后,通过调用本章前面在文档类中添加的函数 GetDocExtent(),获得包围文档类中所有元素的矩形。 然后把这个矩形的左上角存储在 CPrintData 对象的 m_DocRefPoint 成员中,并把包含该文档的文件 的名称放在 m_DocTitle 中。 接下来的两行代码计算文档宽度上的页数以及长度上的页数。计算文档宽度上的页数时,首先 将文档的宽度除以页面打印区域的宽度,然后使用 cmath 头文件中定义的 ceil()库函数,把计算结果 向上舍入为邻近的最大整数。例如,ceil(2.1)返回 3.0, ceil(2.9)也返回 3.0, 而 ceil(–2.1)则返回–2.0。 利用类似的计算方法可以计算出文档长度上的页数。这两个值的乘积是要打印的总页数,也是要提 供给最大页码的值。最后一步是将 CPrintInfo 对象的地址存储到 pInfo 对象的 m_lpUserData 成员中。 不要忘记在 SketcherView.cpp 文件中添加 PrintData.h 的#include 指令。 17.6.4 打印后的清除 由于在堆上创建了 CPrintData 对象,因此必须在使用完以后将它删除。这时要在函数 OnEndPrinting()中添加下列代码: void CSketcherView::OnEndPrinting(CDC* /*pDC*/, CPrintInfo* pInfo) { // Delete our print data object delete static_cast(pInfo->m_lpUserData); } 在 Sketcher 程序中,只需要对这个函数做这么多工作,但是在有些情况下,还有其他一些工作 要做。此处应当一次性地完成最终清除。确保从第二个形参名中删除注释分隔符(/* */);否则函数将 不进行编译。也许您不需要在代码中引用这些参数名,所以默认的实现方式将使它们以注释的形式 存在。由于使用了参数 pInfo,因此必须解除对它的注释;否则编译器将把它报告为未定义。 第 17 章 存储和打印文档 701 在 Sketcher 程序中,不需要对 OnBeginPrinting()函数添加任何代码,但是如果在整个打印过程 中需要使用 GDI 资源(如钢笔),那么需要添加代码以分配这些资源。然后将它们删除,这是 OnEndPrinting()函数中清除过程的一部分。 17.6.5 准备设备上下文 考虑到缩放比例,这时 Sketcher 程序将调用视图对象的 OnPrepareDC()函数,把映射模式设置 为 MM_ANISOTROPIC。就打印而言,要正确地准备设备上下文,必须进行其他一些修改: void CSketcherView::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { CScrollView::OnPrepareDC(pDC, pInfo); CSketcherDoc* pDoc = GetDocument(); pDC->SetMapMode(MM_ANISOTROPIC); // Set the map mode CSize DocSize = pDoc->GetDocSize(); // Get the document size pDC->SetWindowExt(DocSize); // Now set the window extent // Get the number of pixels per inch in x and y int xLogPixels = pDC->GetDeviceCaps(LOGPIXELSX); int yLogPixels = pDC->GetDeviceCaps(LOGPIXELSY); // Calculate the viewport extent in x and y int scale(pDC->IsPrinting() ? 1 : m_Scale); // If we are printing, use scale 1 int xExtent = (DocSize.cx*scale*xLogPixels)/100; int yExtent = (DocSize.cy*scale*yLogPixels)/100; pDC->SetViewportExt(xExtent, yExtent); // Set viewport extent } 对于到打印机以及屏幕的输出,框架将调用这个函数。在进行打印时,应当确保使用比例 1 来 设置从逻辑坐标到设备坐标的映射。如果使一切保持原样,那么输出将采用当前视图比例,但是在 计算需要多少页以及如何设置每一页的原点时,必须考虑比例的问题。 通过调用当前 CDC 对象的 IsPrinting()成员函数,可以确定是否具有打印机设备上下文,如果正 在打印,该函数将返回 TRUE。在具有打印机设备上下文时,只需要把比例设置为 1。当然,必须 修改计算视区大小的语句,使它们使用视图的局部变量 scale,而不是成员变量 m_Scale。 17.6.6 打印文档 在函数 OnPrint()中,可以将数据写入打印机设备上下文。打印每个页面时都将调用这个函数。 需要使用 CSketcherView 类的 Properties 窗口在这个类中添加这个函数的重载函数。从重载函数的列 表中选择 OnPrint,然后在右边的列中单击 OnPrint。 从 CPrintInfo 对象的成员 m_nCurPage 中可以获得当前页的页码,然后使用这个值计算文档中对 应于当前页左上角的位置的坐标。利用一个示例可以充分理解这种方法,假设现在要打印一个 8 页 文档的第 7 页,如图 17-7 所示。 Visual C++ 2012 入门经典(第 6 版) 702 m_nWidths: 宽度数=4 长度数 = 2 1000 单位 750 单位 图 17-7 这些是逻辑坐标,x 正方向是从左到右,y 轴的方向是从上到下,其值是负数。将这个页码减 1, 然后除以文档打印区域所需的页面宽度的数量,取其余数,就可以得到这一页水平位置的索引。把 这个结果乘以 printWidth,就得到该页面左上角的 x 坐标,它相对于包围文档中元素的矩形的左上 角。类似地,将当前页码减 1,然后除以文档水平宽度上所需的页宽度的数量,就可以确定文档垂 直位置的索引。将余数乘以 printLength,将得到该页左上角的相对 y 坐标。可以在下列两个语句中 表示这两种计算: CPrintData* p(static_cast(pInfo->m_lpUserData)); int xOrg = p->m_DocRefPoint.x + p->printWidth* ((pInfo->m_nCurPage - 1)%(p->m_nWidths)); int yOrg = p->m_DocRefPoint.y + p->printLength* ((pInfo->m_nCurPage - 1)/(p->m_nWidths)); 如果能够在每一页的顶部打印出文档的文件名,在每一页的底部打印出页码,就比较理想了。 但是希望能够确保打印的文档数据不会盖住文件名和页码。还希望打印区域在页面的中间。为此, 可以在打印出文件名以后,在打印机设备上下文中移动坐标系统的原点。图 17-8 对此进行了说明。 图 17-8 说明了设备上下文中打印页面区域和要在文档数据的参考框架中打印的页面之间的联 系。图中给出了计算偏移量的表达式,它们是偏移打印该页面的 printWidth×printLength 区域原点 的距离。由于想在页面上所示的虚线区域中打印文档的信息,因此需要将文档中的点 xOrg、yOrg 映射到打印页面中所示的位置,它是由页面原点偏移 xOffset 和 yOffset 值而得到的。 默认情况下,在定义文档中元素时使用的坐标系统的原点将映射为设备环境中的原点,但是可 以进行修改。CDC 对象为此提供了一个函数 SetWindowOrg()。这个函数可以在文档的逻辑坐标系统 中定义一个对应于设备上下文中原点的点,这里是打印机输出所在的点(0,0)。从函数 SetWindowOrg() 返回的旧原点一定要保存起来。在完成当前页的绘制以后,必须还原这个旧原点;否则,在打印下 一页时就不能正确地设置 CPrintInfo 对象的成员 m_rectDraw。 第 17 章 存储和打印文档 703 文件名 页面原点 这个距离是通过 (m_rectDraw.right-printWidth)/2 提供的 xOffset Number of width nW = 4 文档原点 m_DocRefPoint 打印页 文档中的页面原点映射到此处 这个距离是通过 (m_rectDraw.botton - printLength)/2 提供的 yOffset 文档 第 7 页 图 17-8 文档中要映射为页面原点的点具有坐标 xOrg–xOffset、yOrg–yOffset。这也许不容易形象化, 但是记住,通过设置窗口原点,我们将定义映射为视口原点的点。如果考虑到这一点,就应当明白 文档中的点 xOrg、yOrg 正是页面中需要的点。 打印一页文档的完整代码是: // Print a page of the document void CSketcherView::OnPrint(CDC* pDC, CPrintInfo* pInfo) { CPrintData* p(static_cast(pInfo->m_lpUserData)); // Output the document filename pDC->SetTextAlign(TA_CENTER); // Center the following text pDC->TextOut(pInfo->m_rectDraw.right/2, 20, p->m_DocTitle); CString str; str.Format(_T(“Page %u”) , pInfo->m_nCurPage); pDC->TextOut(pInfo->m_rectDraw.right/2, pInfo->m_rectDraw.bottom-20, str); pDC->SetTextAlign(TA_LEFT); // Left justify text // Calculate the origin point for the current page int xOrg = p->m_DocRefPoint.x + p->printWidth*((pInfo->m_nCurPage - 1)%(p->m_nWidths)); int yOrg = p->m_DocRefPoint.y + p->printLength*((pInfo->m_nCurPage - 1)/(p->m_nWidths)); Visual C++ 2012 入门经典(第 6 版) 704 // Calculate offsets to center drawing area on page as positive values int xOffset = (pInfo->m_rectDraw.right - p->printWidth)/2; int yOffset = (pInfo->m_rectDraw.bottom - p->printLength)/2; // Change window origin to correspond to current page & save old origin CPoint OldOrg = pDC->SetWindowOrg(xOrg - xOffset, yOrg - yOffset); // Define a clip rectangle the size of the printed area pDC->IntersectClipRect(xOrg, yOrg, xOrg + p->printWidth, yOrg + p->printLength); OnDraw(pDC); // Draw the whole document pDC->SelectClipRgn(nullptr); // Remove the clip rectangle pDC->SetWindowOrg(OldOrg); // Restore old window origin } 第一步是用 CPrintData 对象的地址初始化本地指针 p,CPrintData 对象存储在 pInfo 指向的对象 的 m_lpUserData 成员中。然后输出存储在 CPrintInfo 对象中的文件名。CDC 对象的函数成员 SetTextAlign()定义后续文本输出的对齐方式,其参考点定义在函数 TextOut()的文本字符串中。对齐 方式由作为参数传递给该函数的常量确定。指定文本水平对齐方式的方法有 3 种,如表 17-5 所示。 表 17-5 常 量 对 齐 方 式 TA_LEFT 参考点位于文本边界矩形的左边,所以文本位于该指定点的右边。这是默认的对齐方式 TA_RIGHT 参考点位于文本边界矩形的右边,所以文本位于该指定点的左边 TA_CENTER 参考点位于文本边界矩形的中心 将文件名在页面上的 x 坐标定义为页面宽度的一半,y 坐标定义为距离页面顶部 20 个单位,即 0.2 英寸。 在把文档文件的名称作为居中文本输出后,在页面底部中间位置输出页码。使用 CString 类的 Format()成员格式化存储在 CPrintInfo 对象的 m_nCurPage 成员中的页码。这定位到页面底部向上 20 个单位处。然后将文本对齐方式重置为文档中文本所用的默认方式 TA_LEFT。 将另一个标志和调整标志进行“或”运算,函数 SetTextAlign()还可以在垂直方向上修改文本的 位置。另一个标志可以是下列任一标志,见表 17-6。 表 17-6 常 量 对 齐 方 式 TA_TOP 将文本边界矩形的顶部与定义文本位置的点对齐,这是默认设置 TA_BOTTOM 将文本边界矩形的底部与定义文本位置的点对齐 TA_BASELINE 将文本所使用字体的基线与定义文本位置的点对齐 函数 OnPrint()的下一个操作是使用前面讨论的方法将文档的一个区域映射为当前页面。通过调 用用于在视图中显示文档的函数 OnDraw(),可以在这个页面上绘制文档。这也许会绘制整个文档, 但是通过定义剪贴矩形,可以限制出现在这个页面的内容。剪贴矩形包围设备上下文中出现输出的 矩形区域,从而禁止输出出现在该矩形以外。也可以定义不规则的形状(称为区),进行剪贴。 在打印设备上下文中定义的初始默认剪贴区域是页面边界。我们定义的剪贴矩形对应于位于页面 中间的 printWidth×printLength 区域。这确保了只能在这个区域进行绘制,而不会重写文件名和页码。 第 17 章 存储和打印文档 705 在通过 OnDraw()函数调用绘制了当前页以后,调用参数为 NULL 的函数 SelectClipRgn()删除了 这个剪贴矩形。如果不这样做,那么将禁止输出的文档标题出现在第一页以后的所有页上,因为它 位于这个剪贴矩形之外。只有在下一次调用函数 IntersectClipRect()时,它才会在打印过程中生效。 最后再次调用函数 SetWindowOrg(),把窗口原点还原到它的原始位置,本章前面对此进行过讨论。 17.6.7 获得文档的打印输出 要获得第一个打印的 Sketcher 文档,只需要构建项目,然后(在修改了所有打字错误以后)执行 这个程序。如果选择 File | Print Preview 菜单项,单击 Two Page 按钮,将出现一个如图 17-9 所示的 窗口。 图 17-9 打印预览功能完全是免费获得的。使用前面为正规多页打印操作提供的代码,框架在打印预览 窗口中产生页面图像。在打印预览窗口中看到的内容应当和在打印页面上出现的内容完全一样。 17.7 小结 本章讨论了如何使用 MFC 支持的序列化过程,以一种可以读取文档并重新构造其组成对象的 形式将文档存储到磁盘上。本章还讨论了如何在 Sketcher 应用程序中打印草图。如果您熟悉在 Sketcher 中序列化和打印的工作过程,那么在任何 MFC 应用程序中实现序列化和打印时,几乎应该 没有困难。 17.8 练习 1. 更新 Sketcher 中 OnPrint()函数的代码,将页码以“Pagn n of m”的形式打印在每一页文档的 底部。其中 n 是当前页的页码,m 是总的页数。 Visual C++ 2012 入门经典(第 6 版) 706 2. 作为 Sketcher 中 CText 类的一个增强功能,修改其实现方式,使比例调整可以正确地应用于 文本(提示:在联机帮助中查找 CreatePointFont()函数)。 17.9 本章主要内容 本章主要内容如表 17-7 所示。 表 17-7 主 题 概 念 序列化 序列化是将对象转换成文件的过程。反序列化则从文件中的数据重新构造对象 MFC 序列化 要在 MFC 应用程序中序列化对象,必须将类识别为可序列化的。为此,在类定 义中使用 DECLARE_SERIALIZABLE() 宏,在包含类实现的文件中使用 IMPLEMENT_ SERIALIZABLE()宏 MFC 应用程序中的可序列化类 对于要在 MFC 应用程序中序列化的类,它的直接或间接基类必须是 CObject,它 必须实现一个无参数的构造函数,并将 Serialize()函数实现为类成员 用 MFC 实现打印 要在 MFC 应用程序中提供打印文档的功能,必须在文档视图类中自己实现 OnPreparePrinting()、OnBeginPrinting()、OnPrepareDC()、OnPrint()和OnEndPrinting() 等函数 CPrintInfo 对象 CPrintInfo 对象是由 MFC 框架创建的,用来存储与打印过程有关的信息。可以将 包含打印信息的自定义类对象的地址存储在 CPrintInfo 对象的指针中
还剩236页未读

继续阅读

下载pdf到电脑,查找使用更方便

pdf的实际排版效果,会与网站的显示效果略有不同!!

需要 6 金币 [ 分享pdf获得金币 ] 9 人已下载

下载pdf

pdf贡献者

pcint

贡献于2013-07-18

下载需要 6 金币 [金币充值 ]
亲,您也可以通过 分享原创pdf 来获得金币奖励!
下载pdf