C---秘籍-问题解决方法-全-
C++ 秘籍:问题解决方法(全)
一、开始 C++
C++ 编程语言是一种强大的低级语言,它允许你编写编译成机器指令的程序,以便在计算机的处理器上执行。这使得 C++ 不同于 C# 和 Java 等较新的语言。这些语言是解释型语言。这意味着它们不是直接在处理器上执行,而是被发送到另一个负责操作计算机的程序。Java 程序是使用 Java 虚拟机(JVM)执行的,C# 程序是由公共语言运行时(CLR)执行的。
由于 C++ 是一种提前编译的语言,它仍然在绝对性能至关重要的领域得到广泛应用。C++ 仍然是最主要使用的编程语言的最明显的领域是视频游戏行业。C++ 允许程序员编写充分利用底层系统架构的应用程序。在从事 C++ 程序员的职业生涯中,您可能会熟悉诸如缓存一致性这样的短语。没有多少其他语言可以让你优化你的应用程序,以适应你的程序运行的处理器。这本书向您介绍了一些在不同时期会影响应用程序性能的陷阱,并向您展示了一些解决这些问题的技术。
现代 C++ 正处于一个语言功能不断更新的时期。情况并非总是如此。尽管从 20 世纪 80 年代早期就出现了,C++ 编程语言直到 1998 年才被标准化。2003 年发布了该标准的一个小的更新和澄清,称为 C++03。2003 年的更新并没有给这种语言增加任何新的特性,但是它澄清了一些已经被忽略的现有特性。其中之一是对 STL vector 模板标准的更新,指定 vector 的成员应该连续存储在内存中。C++11 标准于 2011 年发布,对 C++ 编程语言进行了大规模更新。C++ 获得了模板、lambda 和闭包支持之外的通用类型演绎系统的特性,一个内置的并发库和更多的特性。C++14 对该语言进行了较小的更新,通常建立在 C++14 已经提供的特性之上。诸如从函数中自动返回类型推导这样的特性已经被清理,lambdas 已经更新了新的特性,并且有一些新的方法来定义正确类型的文字值。
本书致力于编写可移植的、符合标准的 C++14 代码。在写作的时候,只要你使用一个提供所有语言特性的编译器,在 Windows、Linux 和 OS X 机器上写 C++14 代码是可能的。为此,本书将在 Windows 和 Ubuntu 上使用 Clang 作为编译器,并将在 OS X 上使用 Xcode。本章的其余部分将重点介绍用 C++ 编写程序所需的软件,然后向您展示如何获得一些可用于 Windows、OS X 和 Linux 操作系统的更常见的选项。
配方 1-1。查找文本编辑器
问题
C++ 程序由许多不同的源文件构成,这些文件必须由一个或多个程序员创建和编辑。源文件是简单的文本文件,通常有两种不同的类型:头文件和源文件。头文件用于在不同文件之间共享有关类型和类的信息,源文件通常用于包含方法和组成程序的实际可执行代码。
解决办法
文本编辑器成为你开始写 C++ 程序所需要的第一个主要软件。在不同的平台上有许多优秀的文本编辑器可供选择。目前我最好的两个选择是免费的 Windows 版 Notepad++ 和 Sublime Text 2,尽管它们不是免费的,但可以在所有主要的操作系统上使用。图 1-1 显示了来自崇高文本 2 的截图。Vim 和 gvim 也是非常好的选项,适用于所有三种操作系统。这些编辑器提供了许多强大的功能,对于愿意学习的人来说是极好的选择。
图 1-1 。来自崇高文本 2 编辑器的截图
注意不要觉得有直接抓取文本编辑器的冲动。本章后面的一些方法涵盖了集成开发环境(ide ),集成开发环境包含了编写、构建和调试 C++ 应用程序所需的所有软件。
图 1-1 显示了一个好的文本编辑器最重要的特征之一:它应该能够在你的源代码中突出不同类型的关键字。你可以在图 1-1 的简单 Hello World 程序中看到,Sublime Text 2 能够突出显示 C++ 关键字include
、int
和return
。它还为主功能name
和琴弦<iostream>
和"Hello World!"
添加了不同颜色的高光。一旦你有了用你选择的文本编辑器编写代码的经验,你将会熟练地扫描你的源文件来聚焦你感兴趣的代码区域,并且语法高亮将会是这个过程中的一个主要因素。
配方 1-2。在 Ubuntu 上安装 Clang
问题
您希望在运行 Ubuntu 的计算机系统上构建支持最新 C++14 语言特性的 C++ 程序。
解决办法
Clang 编译器支持所有最新的 C++14 语言特性,libstdc++ 库支持所有 C++14 STL 特性。
它是如何工作的
Ubuntu 操作系统 配置了软件包库,让你安装 Clang 没有太大困难。您可以在终端窗口中使用apt-get
命令来实现这一点。图 1-2 显示了安装 Clang 应该输入的命令。
图 1-2 。Ubuntu 终端窗口显示安装 Clang 所需的命令
要安装 Clang,你可以在命令行输入下面的命令sudo apt-get install clang
。运行这个命令将导致 Ubuntu 查询它的存储库,并找出安装 Clang 所需的所有依赖项。这个过程完成后,系统会提示您确认是否要安装 Clang 及其依赖项。在图 1-3 中可以看到这个提示。
图 1-3 。apt-get 依赖关系确认提示
此时,您可以按 enter 键继续,因为默认选项是 yes。然后 Ubuntu 会下载并安装你在电脑上安装 Clang 所需的所有软件。您可以通过运行clang
命令来确认这已经成功。图 1-4 显示了如果一切顺利的话应该是什么样子。
图 1-4 。Ubuntu 中一次成功的 Clang 安装
配方 1-3。在窗户上安装金属撞击声
问题
您希望在 Windows 操作系统 上构建基于 C++14 的程序。
解决办法
可以使用 Cygwin for Windows 安装 Clang,构建应用程序。
它是如何工作的
Cygwin 为 Windows 计算机提供了一个类似 Unix 的命令行环境。这非常适合使用 Clang 构建程序,因为安装的 Cygwin 预配置了包存储库,其中包含了在 Windows 计算机上安装和使用 Clang 所需的一切。
您可以从 Cygwin 网站的http://www.cygwin.com
获得 Cygwin 安装程序可执行文件。请务必下载 32 位版本的 Cygwin 安装程序,因为 Cygwin 提供的默认软件包目前仅适用于 32 位环境。
一旦你下载了安装程序,你应该运行它并点击直到你看到要安装的软件包列表。此时,您需要选择 Clang、make 和 libstdc++ 包。图 1-5 显示了选中 Clang 包的 Cygwin 安装程序。
图 1-5 。在 Cygwin 安装程序中过滤 Clang 包
通过单击软件包所在行的跳过区域,可以在安装程序中将软件包标记为要安装。单击一次“跳过”会将包版本移动到最新版本。你应该为 Clang,make 和 libstdc++ 选择最新的包。一旦您选择了所有 3 个包,您可以单击 Next 进入一个窗口,要求您确认这三个包所需的依赖项的安装。
一旦您成功下载并安装了运行 Clang 所需的所有包,您可以通过打开 Cygwin 终端并键入clang
命令来检查它是否成功。你可以在图 1-6 中看到这个输出的结果。
图 1-6 。在 Windows 的 Cywgin 环境下成功运行 Clang
配方 1-4。在 OS X 上安装金属撞击声
问题
您希望在运行 OS X 的计算机上构建基于 C++14 的程序
解决办法
苹果的 Xcode IDE 自带 Clang 作为默认编译器。从 OS X 应用商店安装 Xcode 也会安装 Clang。
它是如何工作的
从 OS X 电脑上的 App Store 安装最新版本的 Xcode。一旦你安装了 Xcode,你可以使用 Spotlight 打开一个终端窗口,然后输入 clang 来查看编译器是否已经安装。图 1-7 显示了这应该是什么样子。
图 1-7 。安装 Xcode 后在 OS X 上运行 Clang
配方 1-5。构建您的第一个 C++ 程序
问题
您希望使用您的计算机从您编写的 C++ 源代码中生成可执行的应用程序。
解决办法
从 C++ 源文件生成可执行文件包括两个步骤:编译和链接。根据您的操作系统,配方 1-2、配方 1-3 或配方 1-4 中的步骤将使您拥有从 C++14 源文件构建应用程序所需的所有软件。您现在已经准备好构建您的第一个 C++14 程序了。创建一个包含您的项目的文件夹,并添加一个名为 HelloWorld.cpp 的文本文件。将清单 1-1 中的代码输入到文件中并保存。
清单 1-1 。你的第一个 C++14 程序
#include <iostream>
#include <string>
int main(void)
{
using namespace std::string_literals;
auto output = "Hello World!"s;
std::cout << output << std::endl;
return 0;
}
清单 1-1 中的代码是一个 C++ 程序,只有在使用 C++14 兼容编译器时才能编译。本章的方法 2-4 包含了如何获得一个编译器的说明,该编译器可用于编译 Windows、Ubuntu 和 OS X 的 C++14 代码。一旦你创建了一个文件夹和包含清单 1-1 中代码的源文件,你就可以构建一个工作应用程序。您可以使用 makefile 来实现这一点。在 HelloWorld.cpp 文件旁边的文件夹中创建一个名为 makefile 的文件。makefile 不应该有文件扩展名,这对于习惯于 Windows 操作系统的开发人员来说可能有点奇怪,但是对于基于 Unix 的操作系统,例如 Linux 和 OS X,这是完全正常的。将清单 1-2 中的代码输入到 makefile 中。
清单 1-2 。构建清单 1-1 中的代码所需的 makefile 文件
HelloWorld: HelloWorld.cpp
clang++ -g -std=c++1y HelloWorld.cpp -o HelloWorld
注意清单 1-2 中
clang++
命令前的空格是一个制表符。您不能用空格替换制表符,因为make
将无法构建。确保 makefile 中的配方总是以制表符开始。
清单 1-2 中的文本由从 HelloWorld.cpp 源文件构建应用程序所需的指令组成。第一行的第一个单词是 makefile 目标的名称。这是构建过程完成后应用程序可执行文件的名称。在这种情况下,我们将构建一个名为 HelloWorld 的可执行文件。接下来是构建程序所需的先决条件。这里您将 HelloWorld.cpp 列为唯一的先决条件,因为它是用于构建可执行文件的唯一源文件。
然后,目标和先决条件后面是为了构建您的应用程序而执行的一系列方法。在这个小示例中,有一行代码调用 clang++ 编译器从 HelloWorld.cpp 文件生成可执行代码。使用–std=c++1y
传递给clang++
的参数要求 Clang 使用 C++14 语言标准进行编译,而–o
开关指定编译过程生成的对象输出文件的名称。
使用命令 shell(例如 Windows 上的 cmd 或 Linux 或 OS X 上的 Terminal)浏览到您创建的用于存储源文件和 makefile 的文件夹,然后键入 make。这将调用 GNU make 程序,并自动读取和执行 makefile。这将把一个可执行文件输出到您可以从命令行运行的同一文件夹中。您现在应该能够做到这一点,并看到在命令行上输出了文本 Hello World。图 1-8 显示了它在 Ubuntu 终端窗口中的样子。
图 1-8 。Ubuntu 终端中 Runnung HelloWorld 生成的输出
配方 1-6。在 Cygwin 或 Linux 中使用 GDB 调试 C++ 程序
问题
您正在编写一个 C++14 程序,并且希望能够从命令行调试应用程序。
解决办法
Cygwin for Windows 和基于 Linux 的操作系统(如 Ubuntu)都可以安装和使用 C++ 应用程序的 GDB 命令行调试器。
它是如何工作的
您可以使用 Cygwin Windows 安装程序或随您喜欢的 Linux 发行版一起安装的软件包管理器来安装 GDB 调试器。这将为您提供一个命令行 C++ 调试器,可用于检查 C++ 程序的功能。您可以使用作为配方 1-5 的一部分生成的源代码、makefile 和应用程序来练习。要为你的程序生成调试信息,你应该更新 makefile 来包含清单 1-3 的内容,并运行 make 来生成一个可调试的可执行文件。
清单 1-3 。生成可调试程序的 makefile
HelloWorld: HelloWorld.cpp
clang++ -g -std=c++1y HelloWorld.cpp -o HelloWorld
一旦你遵循了配方 1-5,更新了 makefile 以包含清单 1-5 中的内容,并生成了一个可执行文件,你就可以在你的应用程序上运行 GDB 了,方法是在你的命令行上浏览文件夹并键入gdb HelloWorld
。在来自清单 1-3 的 makefile 中,传递给 Clang 的新的–g
开关要求编译器在应用程序中生成附加信息,以帮助调试器在程序在调试器中执行时为您提供关于程序的准确信息。
注意你可能会看到一个通知,告诉你如果你以前编译过,你的程序已经是最新的了。如果发生这种情况,只需删除现有的可执行文件。
在 HelloWorld 中运行 GDB 应该会导致您的命令行运行 GDB 并提供如图图 1-9 所示的输出。
图 1-9 。GDB 的一个实例
现在您有了一个正在运行的调试器,可以用来在程序执行时检查它。当 GDB 第一次启动时,程序还没有开始,这允许你在开始之前配置一些断点。要设置断点,您可以使用break
命令或同一命令的简写b
。在 GDB 命令提示符下输入break main
,然后回车。这应该会导致 GDB 将命令以及设置断点的程序的地址和它为所提供的函数检测到的文件名和行号回显给您。现在,您可以在窗口中键入 run 来执行程序,并让 GDB 在断点处停止。输出应类似于图 1-10 所示。
图 1-10 。GDB 在main
中设置的断点处停止时的输出
此时,您有几个选项可以让您继续执行程序。您可以在下面看到最常用命令的列表。
-
step
step
命令用于单步执行将在当前行调用的函数。 -
next
next
命令用于跳过当前行,并在同一功能的下一行停止。 -
finish
finish
命令用于执行当前函数中剩余的所有代码,并在调用当前函数的函数的下一行停止。 -
print <name>
后跟变量名的
print
命令可用于打印程序中变量的值。 -
break
break
命令可与行号、函数名或源文件和行号一起使用,在程序源代码中设置断点。 -
continue
continue
命令用于在断点处暂停后恢复代码执行。 -
until
until
命令可以从循环中继续执行,并在循环执行完成后立即停止在第一行。 -
info
info
命令可以与locals
命令或stack
命令一起使用,以显示关于程序中当前局部变量或堆栈状态的信息。 -
help
你可以键入
help
后接任何命令,让 GDB 告诉你一个给定命令的所有不同用法。
GDB 调试器也可以用命令–tui
运行。这将使您在窗口顶部看到当前正在调试的源文件。你可以在图 1-11 中看到它的样子。
图 1-11 。带有源窗口的 GDB
配方 1-7。在 OS X 上调试你的 C++ 程序
问题
OS X 操作系统没有提供任何安装和使用 GDB 的简单方法。
解决办法
Xcode 附带了 LLDB 调试器,可以代替 GDB 在命令行上使用。
它是如何工作的
LLDB 调试器本质上非常类似于配方 1-6 中使用的 GDB 调试器。在 GDB 和 LLDB 之间转换只是学习如何通过使用各自提供的命令来执行相同的任务。
通过在终端中浏览到包含 HelloWorld 的目录并键入lldb HelloWorld
,可以在 HelloWorld 可执行文件上执行 LLDB。这将给你类似于图 1-12 的输出。
图 1-12 。运行在 OS X 终端上的 LLDB 调试器
注意你需要使用
–g
开关来编译你的程序。如果你不确定的话,看一下清单 1-3 来看看这是怎么回事。
一旦 LLDB 如清单 1-12 所示运行,就可以在 main 的第一行设置一个断点,只需键入breakpoint set –f HelloWorld.cpp –l 8
或b main
即可。您可以使用run
命令开始执行,并让它在您刚刚设置的断点处暂停。当程序停止时,您可以使用next
命令跳过当前行并停在下一行。您可以使用step
命令单步执行当前行的函数,并在函数的第一行停止。finish
命令将退出当前功能。
您可以通过键入q
并按 enter 键来退出 LLDB。重启 LLDB 并键入breakpoint set –f HelloWorld.cpp –l 9
。在此之后使用run
命令,LLDB 应该在应用程序停止的那一行打印源代码。现在可以输入print output
来查看输出变量存储的值。你也可以使用frame variable
命令来查看当前堆栈帧中的所有局部变量。
这些简单的命令将允许您在使用随本书提供的示例时充分使用 LLDB 调试器。使用 LLDB 时,下面的列表可以作为一个方便的备忘单。
-
step
step
命令用于进入当前行要调用的函数。 -
next
next
命令用于跳过当前行,并停在同一功能的下一行。 -
finish
finish
命令用于执行当前函数中剩余的所有代码,并在调用当前函数的函数的下一行停止。 -
print <name>
后跟变量名的
print
命令可用于打印程序中变量的值。 -
breakpoint set –-name <name>
-
breakpoint set –file <name> --line <number>
breakpoint
命令可以与行号、函数名或源文件和行号一起使用,在程序源代码中设置断点。 -
help
你可以键入
help
后接任何命令,让 GDB 告诉你一个给定命令的所有不同用法。
配方 1-8。切换 C++ 编译模式
问题
在编译程序之前,您希望能够在不同的 C++ 标准之间切换。
解决方案
Clang 提供了std
开关,以便您可以指定编译时要使用的 C++ 标准。
它是如何工作的
Clang 默认使用 C++98 标准构建。您可以在 Clang++ 中使用 std 参数来告诉编译器使用非默认标准。清单 1-4 显示了一个 makefile,它被配置成使用 C++14 标准构建一个程序。
清单 1-4 。用 C++14 构建
HelloWorld: HelloWorld.cpp
clang++ -std=c++1y HelloWorld.cpp -o HelloWorld
清单 1-4 中的 makefile 展示了如何指定 Clang 应该使用 C++14 构建你的源文件。这个例子是用 Clang 3.5 编写的,它使用c++1y
命令来表示 C++14。
清单 1-5 展示了如何使用 C++11 来构建一个程序。
清单 1-5 。用 C++11 构建
HelloWorld: HelloWorld.cpp
clang++ -std=c++11 HelloWorld.cpp -o HelloWorld
在清单 1-5 中,你想使用带有std
开关的c++11
选项来构建 C++11。最后,清单 1-6 展示了如何配置 Clang 来用 C++98 显式构建。
清单 1-6 。用 C++98 构建
HelloWorld: HelloWorld.cpp
clang++ -std=c++98 HelloWorld.cpp -o HelloWorld
清单 1-6 中的 makefile 可以用来用 C++98 显式构建。您可以通过完全省略std
命令来获得相同的结果,Clang 将默认使用 C++98 构建。
注意不能保证每个编译器默认使用 C++98。如果不确定哪个标准是默认标准,请查阅编译器文档。您还可以尝试使用 Clang,并使用
c++1z
选项启用其实验性的 C++17 支持!
配方 1-9。使用 Boost 库构建
问题
你想用 Boost 库写一个程序。
解决办法
Boost 作为源代码提供,可以包含在应用程序中并编译到应用程序中。
它是如何工作的
Boost 是一个大型 C++ 库,包含了各种强大的功能。涵盖整个库超出了本书的范围;但是,将使用字符串格式库。您可以在http://www.boost.org/
从 Boost 网站获取 Boost 库。
您可以从 Boost 网站获得包含最新版本的 Boost 库的压缩文件夹。您绝对需要能够包含基本 boost 功能的唯一文件夹是 boost 文件夹本身。我已经下载了 Boost 1.55,因此我在我的项目文件夹中创建了一个名为boost_1_55_0
的文件夹,并将 Boost 文件夹从下载的版本复制到这个位置。
一旦用 Boost 的下载副本建立了项目文件夹,就可以将 Boost 头文件包含到源代码中。清单 1-7 显示了一个使用boost::format
函数 的程序。
清单 1-7 。使用boost::format
#include <iostream>
#include "boost/format.hpp"
using namespace std;
int main()
{
std::cout << "Enter your first name: " << std::endl;
std::string firstName;
std::cin >> firstName;
std::cout << "Enter your surname: " << std::endl;
std::string surname;
std::cin >> surname;
auto formattedName = str( boost::format("%1% %2%"s) % firstName % surname );
std::cout << "You said your name is: " << formattedName << std::endl;
return 0;
}
清单 1-7 中的代码展示了如何在源文件中包含一个 Boost 头文件,以及如何在你的程序中使用该文件的函数。
注意不要担心
format
函数如何工作,如果还不清楚的话,会在第三章中介绍。
您还必须告诉编译器在 makefile 中何处查找 Boost 头文件,否则您的程序将无法编译。清单 1-8 显示了可以用来构建这个程序的 makefile 文件的内容。
清单 1-8 。用 Boost 构建的 makefile
main: main.cpp
clang++ -g -std=c++1y -Iboost_1_55_0 main.cpp -o main
清单 1-8 中的 makefile 将–I
选项传递给 Clang++。该选项用于告诉 Clang,当使用#include
指令包含文件时,您希望在搜索路径中包含给定的文件夹。正如你所看到的,我已经通过了我在项目文件夹中创建的boost_1_55_0
文件夹。该文件夹包含 boost 文件夹,您可以看到在清单 1-7 中包含一个 Boost 头时使用的文件夹。
注意如果您在运行这个示例时遇到问题,并且不确定应该将 Boost 头文件放在哪里,您可以从
www.apress.com/9781484201589
下载本书附带的示例。
二、现代 C++
C++ 编程语言的开发始于 1979 年,当时称为带类的 C 语言。C++ 这个名字在 1983 年被正式采用,在没有采用正式语言标准的情况下,这种语言的发展一直持续到 20 世纪 80 年代和 90 年代。这一切在 1998 年改变了,当时采用了 C++ 编程语言的第一个 ISO 标准。自那时以来,该标准已经发布了三次更新,一次在 2003 年,再次在 2011 年,最近一次在 2014 年。
注意2003 年发布的标准是对 1998 年标准的微小更新,没有引入太多新功能。由于这个原因,在本书中不会详细讨论。
这本书主要关注最新的 C++ 编程标准,C++14。每当我提到 C++ 编程语言时,你可以放心,我说的是当前 ISO 标准所描述的语言。如果我讨论的是 2011 年引入的特性,那么我会明确地将该语言称为 C++11,而对于 2011 年之前引入的任何特性,我将使用 C++98 这个名称。
本章将着眼于最新标准和 C++11 中添加到语言中的编程特性。C++ 的许多现代特性都是在 C++11 标准中添加的,并在 C++14 标准中进行了扩展,因此,在使用支持非最新标准的编译器时,能够识别出它们之间的差异是非常重要的。
食谱 2-1。初始化变量
问题
您希望能够以标准方式初始化所有变量。
解决办法
统一初始化是在 C++11 中引入的,可以用来初始化任何类型的变量。
它是如何工作的
有必要了解 C++98 中变量初始化的缺陷,以理解为什么统一初始化是 C++11 中一个重要的语言特性。清单 2-1 显示了一个包含单个类MyClass
的程序。
清单 2-1 。c++ 最令人烦恼的解析问题
class MyClass
{
private:
int m_Member;
public:
MyClass() = default;
MyClass(const MyClass& rhs) = default;
};
int main()
{
MyClass objectA;
MyClass objectB(MyClass());
return 0;
}
清单 2-1 中的代码会在 C++ 程序中产生一个编译错误。问题出在objectB
的定义上。C++ 编译器不会认为这一行定义了一个名为objectB
的类型为MyClass
的变量,该变量调用一个构造函数,该构造函数接受通过调用MyClass
构造函数构造的对象。这是您可能期望编译器看到的,然而它实际上看到的是一个函数声明。编译器认为这一行声明了一个名为objectB
的函数,它返回一个MyClass
对象,并且有一个单独的、未命名的函数指针指向一个返回一个MyClass
对象的函数,并且没有传递任何参数。
编译清单 2-1 中显示的程序会导致 Clang 生成以下警告:
main.cpp:14:20: warning: parentheses were disambiguated as a function
declaration [-Wvexing-parse]
MyClass objectB(MyClass());
^~~~~~~~~~~
main.cpp:14:21: note: add a pair of parentheses to declare a variable
MyClass objectB(MyClass());
^
( )
Clang 编译器已经正确地识别出在清单 2-1 中输入的代码包含一个令人烦恼的解析问题,甚至建议将作为参数传递的MyClass
构造函数包装在另一对括号中来解决这个问题。C++11 在统一初始化方面提供了另一种解决方案。你可以在的清单 2-2 中看到这一点。
清单 2-2 。使用统一初始化解决令人烦恼的解析问题
class MyClass
{
private:
int m_Member;
public:
MyClass() = default;
MyClass(const MyClass& rhs) = default;
};
int main()
{
MyClass objectA;
MyClass objectB{MyClass{}};
return 0;
}
你可以在清单 2-2 中看到,统一初始化用大括号代替了圆括号。这一语法变化通知编译器您希望使用统一初始化来初始化您的变量。统一初始化可以用来初始化几乎所有类型的变量。
注上一段提到可以用统一初始化来初始化几乎所有变量。在初始化聚集或普通的旧数据类型时可能会有问题,但是你现在不需要担心这些。
防止收缩转换的能力是使用统一初始化的另一个好处。当使用统一初始化时,清单 2-3 中的代码将无法编译。
清单 2-3 。使用统一初始化防止收缩转换
int main()
{
int number{ 0 };
char another{ 512 };
double bigNumber{ 1.0 };
float littleNumber{ bigNumber };
return 0;
}
编译清单 2-3 中的代码时,编译器会抛出错误,因为源代码中有两个收缩转换。第一种情况发生在尝试用文字值 512 定义 char 变量时。char 类型可以存储最大值 255,因此值 512 将缩小到此数据类型。由于这个错误,C++11 或更新的编译器将不会编译这个代码。从 double 类型初始化 float 也是一种收缩转换。当数据从一种类型传输到另一种类型时,如果目标类型无法存储源类型表示的所有值,就会发生收缩转换。在 double 转换为 float 的情况下,精度会丢失,因此编译器不会正确地按原样构建此代码。清单 2-4 中的代码使用一个static_cast
来通知编译器收缩转换是有意的并编译代码。
清单 2-4 。使用 static_cast 编译收缩转换
int main()
{
int number{ 0 };
char another{ static_cast<char>(512) };
double bigNumber{ 1.0 };
float littleNumber{ static_cast<float>(bigNumber) };
return 0;
}
食谱 2-2。用初始化列表初始化对象
问题
您希望从给定类型的多个对象中构造对象。
解决办法
现代 C++ 提供了初始化列表,可用于向构造函数提供许多相同类型的对象。
它是如何工作的
C++11 中的初始化列表建立在统一初始化的基础上,允许你轻松初始化复杂类型。难以用数据初始化的复杂类型的一个常见例子是向量。清单 2-5 显示了对一个标准向量构造器的两个不同调用。
清单 2-5 。构造矢量对象
#include <iostream>
#include <vector>
using namespace std;
int main()
{
using MyVector = vector<int>;
MyVector vectorA( 1 );
cout << vectorA.size() << " " << vectorA[0] << endl;
MyVector vectorB( 1, 10 );
cout << vectorB.size() << " " << vectorB[0] << endl;
return 0;
}
清单 2-5 中的代码可能不会像你第一眼看到的那样。将使用包含 0 的单个int
来初始化vectorA
变量。您可能期望它包含一个包含 1 的整数,但这是不正确的。一个vector
构造函数的第一个参数决定了初始vector
将要存储多少个值,在这个例子中,我们要求它存储一个变量。你可能同样期望vectorB
包含两个值,1 和 10,但是我们这里有一个包含一个值的vector
,而这个值是 10。使用与vectorA
相同的构造函数来构造vectorB
变量,但是它指定一个值来实例化vector
的成员,而不是使用默认值。
清单 2-6 中的代码使用初始化列表和统一初始化来构造一个包含两个指定值元素的向量。
清单 2-6 。使用统一初始化来构造一个vector
#include <iostream>
#include <vector>
using namespace std;
int main()
{
using MyVector = vector<int>;
MyVector vectorA( 1 );
cout << vectorA.size() << " " << vectorA[0] << endl;
MyVector vectorB( 1, 10 );
cout << vectorB.size() << " " << vectorB[0] << endl;
MyVector vectorC{ 1, 10 };
cout << vectorC.size() << " " << vectorC[0] << endl;
return 0;
}
清单 2-6 中的代码创建了三个不同的vector
对象。你可以在图 2-1 的中看到这个程序生成的输出。
图 2-1 。清单 2-6 生成的输出
图 2-1 中所示的控制台输出显示了每个vector
的大小以及存储在每个vector
的第一个元素中的值。您可以看到第一个vector
包含一个元素,其值为 0。第二个vector
也包含一个元素,但是它的值是 10。第三个vector
是使用统一初始化构建的,它包含两个值,第一个元素的值是 1。第二个元素的值将是 10。如果您没有特别注意确保对您的类型使用了正确的初始化类型,这可能会导致程序的行为发生重大变化。清单 2-7 中的代码显示了更加明确地使用initializer_list
来构造vector
。
清单 2-7 。显式初始值设定项 _ 列表用法
#include <iostream>
#include <vector>
using namespace std;
int main()
{
using MyVector = vector<int>;
MyVector vectorA( 1 );
cout << vectorA.size() << " " << vectorA[0] << endl;
MyVector vectorB( 1, 10 );
cout << vectorB.size() << " " << vectorB[0] << endl;
initializer_list<int> initList{ 1, 10 };
MyVector vectorC(initList);
cout << vectorC.size() << " " << vectorC[0] << endl;
return 0;
}
清单 2-7 中的代码包含一个显式的initializer_list
,用于构造一个vector
。清单 2-6 中的代码在使用统一初始化构建vector
时隐式地创建了这个对象。通常很少需要显式地创建这样的初始化列表,但是当你使用统一初始化编写代码时,理解编译器在做什么是很重要的。
食谱 2-3。使用类型演绎
问题
您希望编写可移植的代码,在改变类型时维护成本不高。
解决办法
C++ 提供了 auto 关键字,可用于让编译器自动推断变量的类型。
它是如何工作的
C++98 编译器具有自动推断变量类型的能力,但是这种功能仅在您编写使用模板的代码时可用,并且您省略了类型专门化。现代 C++ 已经将这种类型的演绎支持扩展到更多的场景。清单 2-8 中的代码展示了使用auto
关键字和typeid
方法计算变量的类型。
清单 2-8 。使用auto
关键字
#include <iostream>
#include <typeinfo>
using namespace std;
int main()
{
auto variable = 1;
cout << "Type of variable: " << typeid(variable).name() << endl;
return 0;
}
清单 2-8 中的代码展示了如何在 C++ 中创建一个具有自动推导类型的变量。编译器会自动计算出,你想用这段代码创建一个 int 变量,这就是程序输出的类型。Clang 编译器将输出其整数类型的内部表示,实际上是i
。您可以将这个输出传递给一个名为c++filt
的程序,将其转换成普通的 typename。图 2-2 显示了这是如何实现的。
图 2-2 。用 c++filt 从 Clang 中产生合适的类型输出
c++filt
程序已经成功地将 Clang type i 转换为人类可读的 C++ 类型格式。auto 关键字也适用于类。清单 2-9 显示了这一点。
清单 2-9 。使用auto
和class
#include <iostream>
#include <typeinfo>
using namespace std;
class MyClass
{
};
int main()
{
auto variable = MyClass();
cout << "Type of variable: " << typeid(variable).name() << endl;
return 0;
}
该程序将打印出名称 MyClass,如图 2-3 中的所示。
图 2-3 。对 MyClass 使用auto
不幸的是,有时候auto
关键字产生的结果并不理想。如果你试图将关键字和统一初始化结合起来,你肯定会失败。清单 2-10 显示了统一初始化中自动关键字的使用。
清单 2-10 。使用auto
进行统一初始化
#include <iostream>
#include <typeinfo>
using namespace std;
class MyClass
{
};
int main()
{
auto variable{ 1 };
cout << "Type of variable: " << typeid(variable).name() << endl;
auto variable2{ MyClass{} };
cout << "Type of variable: " << typeid(variable2).name() << endl;
return 0;
}
你可能认为清单 2-10 中的代码会产生一个 int 类型的变量和一个 MyClass 类型的变量,但事实并非如此。图 2-4 显示了程序生成的输出。
图 2-4 。使用 auto 和统一初始化时生成的输出
快速浏览一下图 2-4 显示了使用auto
关键字和统一初始化时遇到的直接问题。C++ 统一初始化特性自动创建一个initializer_list
变量,它包含我们想要的类型的值,而不是类型和值本身。这导致了一个相对简单的建议,当使用auto
定义变量时,不要使用统一初始化。我建议不要使用auto
,即使你想要的类型实际上是一个initializer_list
,因为如果你不混合和匹配你的变量初始化风格,代码更容易理解,更不容易出错。记住最后一条建议,尽可能对局部变量使用 auto。不可能声明一个自动变量而不定义它,因此不可能有一个未定义的局部变量auto
。你可以利用这些知识来减少程序中潜在的错误来源。
食谱 2-4。使用自动功能
问题
您希望使用类型演绎来创建更多的通用函数,以提高代码的可维护性。
解决办法
现代 C++ 允许对函数参数和返回类型使用类型演绎。
它是如何工作的
C++ 允许你在使用两种方法处理函数时使用类型演绎。通过创建一个模板函数并在没有显式特化器的情况下调用该函数,可以推导出函数参数的类型。使用 auto 关键字代替函数的返回类型,可以推导出函数的返回类型。清单 2-11 展示了使用 auto 来推导函数的返回类型。
清单 2-11 。使用auto
推断函数的返回类型
#include <iostream>
using namespace std;
auto AutoFunctionFromReturn(int parameter)
{
return parameter;
}
int main()
{
auto value = AutoFunctionFromReturn(1);
cout << value << endl;
return 0;
}
清单 2-11 中函数的返回类型是自动推导出来的。编译器检查从函数返回的变量的类型,并使用它来推断要返回的类型。这一切都可以正常工作,因为编译器在函数中有了推导类型所需的一切。正在返回parameter
变量,因此编译器可以使用它的类型作为函数的返回类型。
当你需要用 C++11 编译器编译时,事情变得有点复杂。使用 C++11 构建清单 2-11 会导致以下错误。
main.cpp:5:1: error: 'auto' return without trailing return type
auto AutoFunctionFromReturn(int parameter)
清单 2-12 包括一个在 C++11 中工作的自动返回类型演绎的函数。
清单 2-12 。C++11 中的返回类型演绎
#include <iostream>
using namespace std;
auto AutoFunctionFromReturn(int parameter) -> int
{
return parameter;
}
int main()
{
auto value = AutoFunctionFromReturn(1);
cout << value << endl;
return 0;
}
当你看到清单 2-12 中的代码时,你可能会奇怪为什么要这么做。当你总是指定函数的返回类型是一个 int 类型,而你是对的,那么推导函数的返回类型就没什么用了。返回类型推导在那些没有在签名中声明参数类型的函数中更有用。清单 2-13 展示了模板函数的类型演绎。
清单 2-13 。推导 C++11 模板函数的返回类型
#include <iostream>
using namespace std;
template <typename T>
auto AutoFunctionFromParameter(T parameter) -> decltype(parameter)
{
return parameter;
}
int main()
{
auto value = AutoFunctionFromParameter(2);
cout << value << endl;
return 0;
}
清单 2-13 展示了返回类型演绎的一个有用的应用。这一次,函数被指定为模板,因此编译器无法使用参数类型计算出返回类型。C++11 引入了decltype
关键字来恭维auto
关键字。decltype
用来告诉编译器使用给定表达式的类型。表达式可以是一个变量名,但是你也可以在这里给一个函数,decltype 会推导出从函数返回的类型。
此时,代码又回到了起点。C++11 标准允许在函数上使用auto
来推导返回类型,但是要求该类型仍然被指定为尾随返回类型。使用decltype
可以推导出尾随的返回类型,但是这会导致过于冗长的代码。C++14 纠正了这种情况,允许 auto 用于没有尾随返回类型的函数,即使是和模板一起使用,正如你在清单 2-14 中看到的。
清单 2-14 。使用auto
推断模板函数的返回类型
#include <iostream>
using namespace std;
template <typename T>
auto AutoFunctionFromParameter(T parameter)
{
return parameter;
}
int main()
{
auto value = AutoFunctionFromParameter(2);
cout << value << endl;
return 0;
}
食谱 2-5。使用编译时间常数
问题
您希望使用编译时间常数来优化程序的运行时操作。
解决办法
C++ 提供了constexpr
关键字,可以用来保证表达式可以在编译时被求值。
它是如何工作的
constexpr
关键字可以用来创建变量和函数,保证它们的求值可以在编译时进行。如果您向它们添加任何阻止编译时计算的代码,您的编译器将抛出一个错误。清单 2-15 显示了使用一个constexpr
变量来定义一个array
大小的程序。
清单 2-15 。使用constexpr
定义一个array
的大小
#include <array>
#include <cstdint>
#include <iostream>
int main()
{
constexpr uint32_t ARRAY_SIZE{ 5 };
std::array<uint32_t, ARRAY_SIZE> myArray{ 1, 2, 3, 4, 5 };
for (auto&& number : myArray)
{
std::cout << number << std::endl;
}
return 0;
}
清单 2-15 中的变量constexpr
保证了该值可以在编译时被计算。这在这里是必要的,因为array
的大小是在编译程序时必须确定的。清单 2-16 展示了如何扩展这个例子来包含一个constexpr
函数。
清单 2-16 。一个constexpr
功能
#include <array>
#include <cstdint>
#include <iostream>
constexpr uint32_t ArraySizeFunction(int parameter)
{
return parameter;
}
int main()
{
constexpr uint32_t ARRAY_SIZE{ ArraySizeFunction(5) };
std::array<uint32_t, ARRAY_SIZE> myArray{ 1, 2, 3, 4, 5 };
for (auto&& number : myArray)
{
std::cout << number << std::endl;
}
return 0;
}
你可以比清单 2-16 中的代码更进一步,用constexpr
构造器创建一个类。这显示在清单 2-17 中。
清单 2-17 。创建 constexpr 类构造函数
#include <array>
#include <cstdint>
#include <iostream>
class MyClass
{
private:
uint32_t m_Member;
public:
constexpr MyClass(uint32_t parameter)
: m_Member{parameter}
{
}
constexpr uint32_t GetValue() const
{
return m_Member;
}
};
int main()
{
constexpr uint32_t ARRAY_SIZE{ MyClass{ 5 }.GetValue() };
std::array<uint32_t, ARRAY_SIZE> myArray{ 1, 2, 3, 4, 5 };
for (auto&& number : myArray)
{
std::cout << number << std::endl;
}
return 0;
}
清单 2-17 中的代码能够在constexpr
语句中创建一个对象并调用一个方法。这是可能的,因为MyClass
的构造函数被声明为constexpr
构造函数。到目前为止,constexpr
的代码已经与 C++11 编译器兼容。C++14 标准放宽了 C++11 中存在的许多限制。C++11 constexpr
语句不允许做许多普通 C++ 代码可以做的事情。这些事情的例子有创建变量和使用if
语句。清单 2-18 中的代码显示了一个 C++14 constexpr
函数,它可以用来限制一个array
的最大大小。
清单 2-18 。使用 C++14 constexpr
函数
#include <array>
#include <cstdint>
#include <iostream>
constexpr uint32_t ArraySizeFunction(uint32_t parameter)
{
uint32_t value{ parameter };
if (value > 10 )
{
value = 10;
}
return value;
}
int main()
{
constexpr uint32_t ARRAY_SIZE{ ArraySizeFunction(15) };
std::array<uint32_t, ARRAY_SIZE> myArray{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
for (auto&& number : myArray)
{
std::cout << number << std::endl;
}
return 0;
}
清单 2-18 中的代码扩展了清单 2-16 中的 C++11 兼容代码,增加了一个声明变量并使用if
语句的函数。用 C++11 编译器编译这段代码会导致下面的错误。
main.cpp:7:14: warning: variable declaration in a constexpr function is a C++1y extension [-Wc++1y-extensions]
uint32_t value{ parameter };
^
main.cpp:8:5: warning: use of this statement in a constexpr function is a C++1y extension [-Wc++1y-extensions]
if (value > 10 )
^
main.cpp:17:24: error: constexpr variable 'ARRAY_SIZE' must be initialized by a constant expression
constexpr uint32_t ARRAY_SIZE{ ArraySizeFunction(15) };
出现两个警告,表明constexpr
函数不能在constexpr
上下文中使用。这不是编译错误,因为该函数仍然可以在非constexpr
上下文中使用。当函数被用来初始化一个constexpr
变量时,实际的错误被抛出。
配方 2-6。使用 Lambdas
问题
你想编写利用未命名函数对象的程序。
解决办法
C++ 提供了 lambdas ,可以用来创建闭包,并且可以在代码中传递。
它是如何工作的
C++11 中引入的 lambda 语法一开始可能会有点混乱。清单 2-19 显示了一个简单的程序例子,它使用 lambda 来打印出一个数组中的所有值。
清单 2-19 。使用λ来打印array
值
#include <algorithm>
#include <array>
#include <cstdint>
#include <iostream>
int main()
{
using MyArray = std::array<uint32_t, 5>;
MyArray myArray{ 1, 2, 3, 4, 5 };
std::for_each(myArray.cbegin(),
myArray.cend(),
[](auto&& number) {
std::cout << number << std::endl;
});
return 0;
}
这段代码展示了 lambda 是如何在 C++ 源代码中定义的。lambda 的语法如下:
[] () {};
大括号表示捕获块。lambda 使用一个捕获块来捕获要在 lambda 中使用的现有变量。清单 2-19 中的代码不需要捕获任何变量,因此它是空的。括号表示参数块,就像在普通函数中一样。清单 2-19 中的 lambda 有一个类型为auto&&
的单一参数。std::for_each
算法将给定的函数应用于序列中的每个元素。这里的函数恰好是编译器在遇到 lambda 语法并将其传递给for_each
函数时创建的闭包。这里有一个微妙的术语差异,您应该熟悉一下。lambda 是定义匿名或未命名函数的源代码结构。编译器使用这个语法从 lambda 创建一个闭包对象。
闭包可以被一个变量引用,如清单 2-20 中的所示。
清单 2-20 。引用变量中的闭包
#include <algorithm>
#include <array>
#include <cstdint>
#include <iostream>
#include <typeinfo>
int main()
{
using MyArray = std::array<uint32_t, 5>;
MyArray myArray{ 1, 2, 3, 4, 5 };
auto myClosure = [](auto&& number) {
std::cout << number << std::endl;
};
std::cout << typeid(myClosure).name() << std::endl;
std::for_each(myArray.begin(),
myArray.end(),
myClosure);
return 0;
}
清单 2-20 中的例子将 lambda 捕获到一个自动类型的变量中。图 2-5 显示了由此产生的输出。
图 2-5 。该类型由typeid
输出时传递一个闭包
图 2-5 显示了由清单 2-20 中的myClosure
变量存储的闭包的类型。这里自动生成的类型并不特别有用,但是 C++ 确实提供了一种方法来传递任何类型的对象,这些对象可以像函数一样被调用。function
模板在功能头中提供,是 STL 的一部分。这个模板接受对象所代表的函数的签名。你可以在清单 2-21 中看到这段代码。
清单 2-21 。将闭包传递给Function
#include <algorithm>
#include <array>
#include <cstdint>
#include <functional>
#include <iostream>
#include <typeinfo>
using MyArray = std::array<uint32_t, 5>;
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
{
MyArray myArray{ 1, 2, 3, 4, 5 };
std::for_each(myArray.begin(),
myArray.end(),
myFunction);
}
int main()
{
auto myClosure = [](auto&& number) {
std::cout << number << std::endl;
};
std::cout << typeid(myClosure).name() << std::endl;
PrintArray(myClosure);
return 0;
}
你现在可以创建闭包并使用函数模板在你的程序中传递它们,如清单 2-21 所示。这允许你给你的程序添加一些在 C++98 中很难实现的东西。清单 2-22 展示了一种使用捕获块通过 lambda 将数组复制到vector
中的方法。
清单 2-22 。使用 Lambda 捕获功能
#include <algorithm>
#include <array>
#include <cstdint>
#include <functional>
#include <iostream>
#include <typeinfo>
#include <vector>
using MyArray = std::array<uint32_t, 5>;
using MyVector = std::vector<MyArray::value_type>;
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
{
MyArray myArray{ 1, 2, 3, 4, 5 };
std::for_each(myArray.begin(),
myArray.end(),
myFunction);
}
int main()
{
MyVector myCopy;
auto myClosure = &myCopy {
std::cout << number << std::endl;
myCopy.push_back(number);
};
std::cout << typeid(myClosure).name() << std::endl;
PrintArray(myClosure);
std::cout << std::endl << "My Copy: " << std::endl;
std::for_each(myCopy.cbegin(),
myCopy.cend(),
[](auto&& number){
std::cout << number << std::endl;
});
return 0;
}
清单 2-22 中的代码包含了一个 lambda 捕获的使用,用来在闭包中存储对对象myCopy
的引用。然后可以在 lambda 中使用这个对象,并将数组的每个成员都推送到它上面。main
函数通过打印由myCopy
存储的所有值来结束,以表明由于引用捕获,闭包与 main 共享同一个vector
。使用&
操作符将捕获指定为参考捕获。如果省略的话,vector
将被复制到闭包中,并且main
中的myCopy vector
将保持为空。
通过值而不是引用来捕获myCopy
会导致另一个问题。编译器为 lambda 创建的类型将不再是与用于声明函数签名的参数兼容的参数。清单 2-23 显示了 lambda 使用值捕获来复制myCopy
。
清单 2-23 。通过值捕获myCopy
#include <algorithm>
#include <array>
#include <cstdint>
#include <functional>
#include <iostream>
#include <typeinfo>
#include <vector>
using MyArray = std::array<uint32_t, 5>;
using MyVector = std::vector<MyArray::value_type>;
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
{
MyArray myArray{ 1, 2, 3, 4, 5 };
std::for_each(myArray.begin(),
myArray.end(),
myFunction);
}
int main()
{
MyVector myCopy;
auto myClosure = myCopy {
std::cout << number << std::endl;
myCopy.push_back(number);
};
std::cout << typeid(myClosure).name() << std::endl;
PrintArray(myClosure);
std::cout << std::endl << "My Copy: " << std::endl;
std::for_each(myCopy.cbegin(),
myCopy.cend(),
[](auto&& number){
std::cout << number << std::endl;
});
return 0;
}
清单 2-23 中的代码不会被编译,你的编译器也不可能给你一个有意义或有帮助的错误信息。当试图在 Windows 上使用 CygwinT3 编译这段代码时,Clang 提供了以下错误输出。
$ make
clang++ -g -std=c++1y main.cpp -o main
main.cpp:26:13: error: no matching member function for call to 'push_back'
myCopy.push_back(number);
~~~~~~~^~~~~~~~~
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/functional:2149:27: note: in instantiation of function template
specialization 'main()::<anonymous class>::operator()<unsigned int>' requested here
using _Invoke = decltype(__callable_functor(std::declval<_Functor&>())
^
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/functional:2158:2: note: in instantiation of template type alias
'_Invoke' requested here
using _Callable
^
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/functional:2225:30: note: in instantiation of template type alias
'_Callable' requested here
typename = _Requires<_Callable<_Functor>, void>>
^
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/functional:2226:2: note: in instantiation of default argument for
'function<<lambda at main.cpp:24:22> >' required here
function(_Functor);
^~~~~~~~
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/functional:2226:2: note: while substituting deduced template arguments
into function template 'function' [with _Functor = <lambda at main.cpp:24:22>, $1 = <no value>]
function(_Functor);
^
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/bits/stl_vector.h:913:7: note: candidate function not viable: 'this'
argument has type 'const MyVector' (aka 'const vector<MyArray::value_type>'), but method is not marked const
push_back(const value_type& __x)
^
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/bits/stl_vector.h:931:7: note: candidate function not viable: 'this'
argument has type 'const MyVector' (aka 'const vector<MyArray::value_type>'), but method is not marked const
push_back(value_type&& __x)
^
main.cpp:30:5: error: no matching function for call to 'PrintArray'
PrintArray(myClosure);
^~~~~~~~~~
main.cpp:12:6: note: candidate function not viable: no known conversion from '<lambda at main.cpp:24:22>' to 'const
std::function<void (MyArray::value_type)>' for 1st argument
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
^
2 errors generated.
makefile:2: recipe for target 'main' failed
make: *** [main] Error 1
考虑到 Clang 输出的冗长且令人困惑的错误消息,您可能会认为代码远未处于工作状态,但是您可能会惊讶地发现这可以用一个关键字mutable
来解决。清单 2-24 显示了处于正确编译状态的代码。
清单 2-24 。创建一个mutable
闭包
#include <algorithm>
#include <array>
#include <cstdint>
#include <functional>
#include <iostream>
#include <typeinfo>
#include <vector>
using MyArray = std::array<uint32_t, 5>;
using MyVector = std::vector<MyArray::value_type>;
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
{
MyArray myArray{ 1, 2, 3, 4, 5 };
std::for_each(myArray.begin(),
myArray.end(),
myFunction);
}
int main()
{
MyVector myCopy;
auto myClosure = myCopy mutable {
std::cout << number << std::endl;
myCopy.push_back(number);
};
std::cout << typeid(myClosure).name() << std::endl;
PrintArray(myClosure);
std::cout << std::endl << "My Copy: " << std::endl;
std::for_each(myCopy.cbegin(),
myCopy.cend(),
[](auto&& number){
std::cout << number << std::endl;
});
return 0;
}
清单 2-24 包含了你在上面看到的所有错误输出的解决方案。mutable 关键字用于告诉编译器 lambda 函数应该生成一个闭包,其中包含已经通过值复制的非const
成员。
默认情况下,编译器在遇到 lambda 函数时创建的闭包是const
。这导致编译器为闭包创建一个类型,该类型不能再隐式转换为标准函数指针。当您试图使用 lambda 函数来生成不适合您的代码的闭包时,编译器生成的错误消息可能会非常混乱,因此除了正确学习如何使用 lambda 函数并经常编译以发现您做出了编译器无法处理的更改之外,这里没有真正的解决方案。
在试图编译到目前为止在这个菜谱中看到的代码时,您可能会遇到的下一个问题是使用不支持 C++14 的 C++11 编译器进行编译。这里的问题是 c++ 11 lambda 不支持 auto 关键字作为参数。用 C++11 编译器构建清单 2-24 会产生以下输出。
clang++ -g -std=c++11 main.cpp -o main
main.cpp:24:31: error: 'auto' not allowed in lambda parameter
auto myClosure = myCopy mutable {
^~~~
main.cpp:30:5: error: no matching function for call to 'PrintArray'
PrintArray(myClosure);
^~~~~~~~~~
main.cpp:12:6: note: candidate function not viable: no known conversion from '<lambda at main.cpp:24:22>' to 'const
std::function<void (MyArray::value_type)>' for 1st argument
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
^
main.cpp:35:5: error: 'auto' not allowed in lambda parameter
[](auto&& number){
^~~~
In file included from main.cpp:1:
In file included from /usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/algorithm:62:
/usr/lib/gcc/i686-pc-cygwin/4.9.2/include/c++/bits/stl_algo.h:3755:2: error: no matching function for call to object
of type '<lambda at main.cpp:35:2>'
__f(*__first);
^~~
main.cpp:33:10: note: in instantiation of function template specialization
'std::for_each<__gnu_cxx::__normal_iterator<const unsigned int *, std::vector<unsigned int,
std::allocator<unsigned int> > >, <lambda at main.cpp:35:2> >' requested here
std::for_each(myCopy.cbegin(),
^
main.cpp:35:2: note: candidate template ignored: couldn't infer template argument '$auto-0-0'
[](auto&& number){
^
4 errors generated.
makefile:2: recipe for target 'main' failed
make: *** [main] Error 1
谢天谢地,这是一个比试图编译清单 2-23 时更清晰的消息,很明显 C++11 不支持 lambda 函数参数的自动类型推导。清单 2-25 显示了构建一个使用 lambda 函数将array
复制到vector
的工作程序所需的代码。
清单 2-25 。一个 C++11 兼容的 Lambda 函数
#include <algorithm>
#include <array>
#include <cstdint>
#include <functional>
#include <iostream>
#include <typeinfo>
#include <vector>
using MyArray = std::array<uint32_t, 5>;
using MyVector = std::vector<MyArray::value_type>;
void PrintArray(const std::function<void(MyArray::value_type)>& myFunction)
{
MyArray myArray{ 1, 2, 3, 4, 5 };
std::for_each(myArray.begin(),
myArray.end(),
myFunction);
}
int main()
{
MyVector myCopy;
auto myClosure = &myCopy {
std::cout << number << std::endl;
myCopy.push_back(number);
};
std::cout << typeid(myClosure).name() << std::endl;
PrintArray(myClosure);
std::cout << std::endl << "My Copy: " << std::endl;
std::for_each(myCopy.cbegin(),
myCopy.cend(),
[](const MyVector::value_type&number){
std::cout << number << std::endl;
});
return 0;
}
清单 2-25 中的代码在 C++11 编译器上运行良好,但是它确实导致了 lambda 函数在不同类型之间的可移植性稍差。用于打印来自myCopy
的值的 lambda 函数现在只能与由MyVector::value_type
定义的类型一起使用,而 C++14 版本可以与可以作为输入传递给cout
的任何类型一起重用。
不用说,这些代码都不能用 C++98 编译器编译,因为 C++98 不支持 lambda 函数。
食谱 2-7。与时间一起工作
问题
你想写可移植的程序,知道当前时间或它们的执行时间。
解决办法
现代 C++ 提供了 STL 模板和类,这些模板和类提供了可移植的时间处理能力。
它是如何工作的
获取当前日期和时间
C++11 提供对给定计算机系统中不同实时时钟的访问。每个时钟的实现可能会有所不同,这取决于您运行的计算机系统本身,但是每个时钟的总体意图是相同的。您可以使用system_clock
从系统范围的实时时钟中查询当前时间。这意味着当你的程序运行时,你可以使用这种类型的时钟来获取计算机的当前日期和时间。清单 2-26 展示了这是如何实现的。
清单 2-26 。获取当前日期和时间
#include <ctime>
#include <chrono>
#include <iostream>
using namespace std;
using namespace chrono;
int main()
{
auto currentTimePoint = system_clock::now();
auto currentTime = system_clock::to_time_t( currentTimePoint );
auto timeText = ctime( ¤tTime );
cout << timeText << endl;
return 0;
}
清单 2-26 中的程序展示了如何从system_clock
获取当前时间。您可以使用system_clock::now
方法 来完成这项工作。从 now 返回的对象是一个time_point
,它包含了某个时期的时间偏移量。历元是系统用来补偿所有其他时间的参考时间。您将不必担心纪元,因为您所有的时间工作都使用同一个时钟。然而,你必须意识到,如果系统使用不同的时间,一台计算机的时间可能无法直接转移到另一台计算机。
time_point 结构不能直接打印出来,也没有方法将其转换为字符串,但是该类提供了一个方法将time_point
对象转换为time_t
对象。time_t 类型是一种旧的 C 类型,可以使用ctime
函数转换成字符串表示。你可以在图 2-6 中看到运行该程序的结果。
图 2-6 。打印到终端的当前时间
比较时间
您还可以使用 STL 时间功能来比较时间。清单 2-27 展示了如何比较一个时间和另一个时间。
清单 2-27 。比较时间
#include <ctime>
#include <chrono>
#include <iostream>
#include <thread>
using namespace std;
using namespace chrono;
using namespace literals;
int main()
{
auto startTimePoint = system_clock::now();
this_thread::sleep_for(5s);
auto endTimePoint = system_clock::now();
auto timeTaken = duration_cast<milliseconds>(endTimePoint - startTimePoint);
cout << "Time Taken: " << timeTaken.count() << endl;
return 0;
}
清单 2-27 显示了你可以多次调用时钟上的now
方法并获取不同的值。程序在startTimePoint
变量中获取一个时间,然后在当前执行线程上调用sleep_for
方法。这个调用导致程序休眠 5 秒钟,并在程序恢复后再次调用system_clock::now
方法。此时,您有两个 time_point 对象,可用于将一个从另一个中减去。然后可以使用duration_cast
将减法的结果转换成具有给定持续时间的具体时间。有效的持续时间类型有hours
、minutes
、seconds
、milliseconds
、microseconds
和nanoseconds
。然后在 duration 对象上使用count
方法来获得调用now
之间经过的实际毫秒数。
注意清单 2-27 中的代码使用了 C++14 标准用户定义的文字。传递给 sleep for 的 5s 定义了 5 秒的字面量。还有为
h
(小时)min
(分钟)s
(秒)ms
(毫秒)us
(微秒)和ns
(纳秒)定义的文字。这些字面值都可以应用于一个整数字面值,以通知编译器您想要创建一个具有给定时间类型的duration
对象的字面值。将 s 应用于一个字符字面量,比如"A String"s
,告诉编译器创建一个std::string
类型的字面量。这些文字在std::literals
名称空间中定义,是 C++14 独有的特性,这意味着它们不能在 C++11 或 C++98 代码中使用。
图 2-7 显示了该程序运行时产生的输出。
图 2-7 。清单 2-27 中的几次运行的输出
图 2-7 显示 sleep_for 方法并不是 100%准确,但是每次运行都相当接近 5000 毫秒。现在,您可以看到如何使用 now 方法来比较两个time_point
,并且不难想象您可以创建一个if
语句,该语句只在一定时间过后才执行。
食谱 2-8。理解左值和右值引用
问题
C++ 包含左值引用和右值引用的区别。您需要能够理解这些概念来编写最佳的 C++ 程序。
解决办法
现代 C++ 包含两种不同的引用操作符,&
(左值)和&&
(右值)。这些与移动语义一起减少了程序中复制对象所花费的时间。
它是如何工作的
移动语义是现代 C++ 编程语言的主要特征之一。它们的有用性被大大夸大了,不熟悉现代 C++ 编程的程序员可能倾向于一头扎进这个闪亮的新特性,实际上由于缺乏对何时以及为何使用右值引用而不是左值引用的理解,他们的程序变得更糟。
简而言之,右值引用应该用于移动构造或移动赋值对象,以在适当的时候代替复制操作。移动语义不应该用于替换通过常量引用向方法传递参数。移动操作可能比复制快,在最坏的情况下,它可能比复制慢,并且总是比通过常量引用传递慢。这个菜谱将向您展示左值引用、右值引用、复制和移动类构造函数和操作符之间的区别,并展示一些与它们相关的性能问题。
清单 2-28 中的代码显示了一个简单类的实现,它使用一个静态计数器值来跟踪在任何给定时间内存中对象的数量。
清单 2-28 。计算实例数量的类
#include <iostream>
using namespace std;
class MyClass
{
private:
static int s_Counter;
int* m_Member{ &s_Counter };
public:
MyClass()
{
++(*m_Member);
}
~MyClass()
{
--(*m_Member);
m_Member = nullptr;
}
int GetValue() const
{
return *m_Member;
}
};
int MyClass::s_Counter{ 0 };
int main()
{
auto object1 = MyClass();
cout << object1.GetValue() << endl;
{
auto object2 = MyClass();
cout << object2.GetValue() << endl;
}
auto object3 = MyClass();
cout << object3.GetValue() << endl;
return 0;
}
清单 2-28 中的s_Counter static
成员计算在任何给定时间内存中存在的类的活动实例的数量。这是通过将static
初始化为 0 并通过成员整数指针预递增MyClass
构造函数中的值来实现的。在~MyClass
中s_Counter
值也会递减,以确保该数字不会失控。当您看到运行中的 move 构造函数时,对非常规设置的需求就变得很明显了。该程序生成的输出如图 2-8 所示。
图 2-8 。行动中的s_Counter
变量
现在,您可以扩展MyClass
来包含一个复制构造函数,并确定在任何给定时间它对内存中对象数量的影响。清单 2-29 显示了一个包含MyClass
复制构造器的程序。
清单 2-29 。复制我的类
#include <iostream>
using namespace std;
class MyClass
{
private:
static int s_Counter;
int* m_Member{ &s_Counter };
public:
MyClass()
{
++(*m_Member);
cout << "Constructing: " << GetValue() << endl;
}
~MyClass()
{
--(*m_Member);
m_Member = nullptr;
cout << "Destructing: " << s_Counter << endl;
}
MyClass(const MyClass& rhs)
: m_Member{ rhs.m_Member }
{
++(*m_Member);
cout << "Copying: " << GetValue() << endl;
}
int GetValue() const
{
return *m_Member;
}
};
int MyClass::s_Counter{ 0 };
MyClass CopyMyClass(MyClass parameter)
{
return parameter;
}
int main()
{
auto object1 = MyClass();
{
auto object2 = MyClass();
}
auto object3 = MyClass();
auto object4 = CopyMyClass(object3);
return 0;
}
清单 2-29 中的代码添加了一个复制构造函数和一个将object3
复制到object4
中的函数。这需要两个副本,一个将object3
复制到参数中,一个将参数复制到object4
中。图 2-9 显示了两个复制操作已经发生,并且还有两个后续的析构函数被调用来销毁这些对象。
图 2-9 。复制运行中的构造函数
移动构造函数可以用来降低复制构造函数的复杂性。在运行中会有同样多的对象,但是您可以在 move 构造函数中安全地浅层复制一个对象,这要感谢它们所传递的右值引用类型。右值引用是编译器对变量引用的对象是临时对象的保证。这意味着您可以自由地分解对象,这样,与需要保留预先存在的状态相比,您可以更快地实现复制操作。清单 2-30 展示了如何添加一个移动构造函数到MyClass
。
清单 2-30 。将移动构造函数添加到MyClass
#include <iostream>
using namespace std;
class MyClass
{
private:
static int s_Counter;
int* m_Member{ &s_Counter };
public:
MyClass()
{
++(*m_Member);
cout << "Constructing: " << GetValue() << endl;
}
~MyClass()
{
if (m_Member)
{
--(*m_Member);
m_Member = nullptr;
cout << "Destructing: " << s_Counter << endl;
}
else
{
cout << "Destroying a moved-from instance" << endl;
}
}
MyClass(const MyClass& rhs)
: m_Member{ rhs.m_Member }
{
++(*m_Member);
cout << "Copying: " << GetValue() << endl;
}
MyClass(MyClass&& rhs)
: m_Member{ rhs.m_Member }
{
cout << hex << showbase;
cout << "Moving: " << &rhs << " to " << this << endl;
cout << noshowbase << dec;
rhs.m_Member = nullptr;
}
int GetValue() const
{
return *m_Member;
}
};
int MyClass::s_Counter{ 0 };
MyClass CopyMyClass(MyClass parameter)
{
return parameter;
}
int main()
{
auto object1 = MyClass();
{
auto object2 = MyClass();
}
auto object3 = MyClass();
auto object4 = CopyMyClass(object3);
return 0;
}
清单 2-30 中的代码向 MyClass 添加了一个 move 构造函数。这对正在运行的代码有直接的影响。在图 2-10 中可以看到 move 构造函数正在被调用。
图 2-10 。使用移动构造函数
编译器已经意识到,在 return 语句结束后,不需要维护清单 2-30 中的参数状态。这意味着代码可以调用一个 move 构造函数来创建object4
。这为代码中可能的优化创建了一个场景。这个例子很简单,因此对性能和内存的好处很小。如果这个类更复杂,那么你就可以节省同时在内存中存储两个对象所需的内存,以及从一个对象复制到另一个对象所需的时间。这样做的性能优势可以在清单 2-31 中看到。
清单 2-31 。比较复制构造函数和移动构造函数
#include <chrono>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
using namespace chrono;
using namespace literals;
class MyClass
{
private:
vector<string> m_String{
"This is a pretty long string that"
" must be copy constructed into"
" copyConstructed!"s
};
int m_Value{ 1 };
public:
MyClass() = default;
MyClass(const MyClass& rhs) = default;
MyClass(MyClass&& rhs) = default;
int GetValue() const
{
return m_Value;
}
};
int main()
{
using MyVector = vector<MyClass>;
constexpr unsigned int ITERATIONS{ 1000000U };
MyVector copyConstructed(ITERATIONS);
int value{ 0 };
auto copyStartTime = high_resolution_clock::now();
for (unsigned int i=0; i < ITERATIONS; ++i)
{
MyClass myClass;
copyConstructed.push_back(myClass);
value = myClass.GetValue();
}
auto copyEndTime = high_resolution_clock::now();
MyVector moveConstructed(ITERATIONS);
auto moveStartTime = high_resolution_clock::now();
for (unsigned int i=0; i < ITERATIONS; ++i)
{
MyClass myClass;
moveConstructed.push_back(move(myClass));
value = myClass.GetValue();
}
auto moveEndTime = high_resolution_clock::now();
cout << value << endl;
auto copyDuration =
duration_cast<milliseconds>(copyEndTime - copyStartTime);
cout << "Copy lasted: " << copyDuration.count() << "ms" << endl;
auto moveDuration =
duration_cast<milliseconds>(moveEndTime - moveStartTime);
cout << "Move lasted: " << moveDuration.count() << "ms" << endl;
return 0;
}
清单 2-31 中的代码使用了default
关键字来通知编译器我们想要使用这个类的默认构造函数、复制构造函数和移动构造函数。这在这里是有效的,因为MyClass
不需要手动的内存管理或行为。我们只是想构造、复制或移动成员m_String
和m_Value
。m_Value
变量用于防止编译器过度优化我们的例子并产生意想不到的结果。在图 2-11 中,你可以看到移动构造函数比复制构造函数更快。
图 2-11 。显示移动构造函数可能比复制构造函数更快
食谱 2-9。使用托管指针
问题
您希望在 C++ 程序中自动执行管理内存的任务。
解决办法
现代 C++ 提供了自动管理动态分配内存的能力。
它是如何工作的
使用唯一指针
C++ 提供了三种智能指针类型,可用于自动管理动态分配对象的生存期。清单 2-32 展示了一个unique_
ptr
的用法。
清单 2-32 。使用unique_ptr
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
int m_Value{ 10 };
public:
MyClass()
{
cout << "Constructing!" << endl;
}
~MyClass()
{
cout << "Destructing!" << endl;
}
int GetValue() const
{
return m_Value;
}
};
int main()
{
unique_ptr<MyClass> uniquePointer{ make_unique<MyClass>() };
cout << uniquePointer->GetValue() << endl;
return 0;
}
清单 3-32 中的代码设法创建和销毁一个动态分配的对象,从来没有使用过new
或delete
。当unique_ptr
实例超出范围时,make_unique
模板处理调用new
,而unique_ptr
对象处理调用delete
。不幸的是,make_unique 模板是 C++14 的一个特性,在 C++11 中不存在。清单 2-33 中的代码展示了如何纠正这一点。
清单 2-33 。创建您自己的make_unique
#include <iostream>
#include <memory>
using namespace std;
#if __cplusplus > 200400L && __cplusplus < 201200L
template <typename T, typename... Args>
unique_ptr<T> make_unique(Args... args)
{
return unique_ptr<T>{ new T(args...) };
}
#endif
class MyClass
{
private:
string m_Name;
int m_Value;
public:
MyClass(const string& name, int value)
: m_Name{ name }
, m_Value{ value }
{
cout << "Constructing!" << endl;
}
~MyClass()
{
cout << "Destructing!" << endl;
}
const string& GetName() const
{
return m_Name;
}
int GetValue() const
{
return m_Value;
}
};
int main()
{
unique_ptr<MyClass> uniquePointer{
make_unique<MyClass>("MyClass", 10) };
cout << uniquePointer->GetName() << endl;
cout << uniquePointer->GetValue() << endl;
return 0;
}
清单 2-33 中的代码使用了另一个 C++11 特性来创建一个 make_unique 模板。这个模板是一个可变的模板,它可以接受任意多的参数。这在 make unique 的调用中得到了证明,其中一个字符串和一个 int 被传递给了MyClass
构造函数。__cplusplus
预处理符号用于检测编译器正在编译的 C++ 版本。您可能需要确保这与您正在使用的编译器一起正常工作,因为并非所有编译器都能正确实现这一点。该代码将使用用户提供的make_unique
模板在 C++11 中编译,并将使用标准提供的make_unique
模板在 C++14 中编译。
唯一指针正如你所期望的那样,它们是唯一的,因此你的代码不能有一个以上的unique_ptr
实例同时指向同一个对象。它通过阻止对unqiue_ptr
实例的复制操作来实现这一点。然而一个unique_ptr
可以被移动,这允许你在你的程序中传递一个unique_ptr
。清单 2-34 展示了如何使用移动语义在你的程序中传递一个unqiue_
ptr
。
清单 2-34 。移动一个unqiue_ptr
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
string m_Name;
int m_Value;
public:
MyClass(const string& name, int value)
: m_Name{ name }
, m_Value{ value }
{
cout << "Constructing!" << endl;
}
~MyClass()
{
cout << "Destructing!" << endl;
}
const string& GetName() const
{
return m_Name;
}
int GetValue() const
{
return m_Value;
}
};
using MyUniquePtr = unique_ptr<MyClass>;
auto PassUniquePtr(MyUniquePtr ptr)
{
cout << "In Function Name: " << ptr->GetName() << endl;
return ptr;
}
int main()
{
auto uniquePointer = make_unique<MyClass>("MyClass", 10);
auto newUniquePointer = PassUniquePtr(move(uniquePointer));
if (uniquePointer)
{
cout << "First Object Name: " << uniquePointer->GetName() << endl;
}
cout << "Second Object Name: " << newUniquePointer->GetName() << endl;
return 0;
}
清单 2-34 中的代码将一个unique_ptr
实例移动到一个函数中。然后,该实例从函数中移回第二个unique_ptr
对象。没有理由为什么相同的unique_ptr
不能在 main 中使用,除非表明原来的实例在被移走后无效。这在检查指针是否有效的if
调用中很明显,因为当代码被执行时,这将失败。可以以这种方式使用unique_ptr
,一旦实例指向的对象超出范围而没有被移走,它将被删除。该程序的输出如图 2-12 所示。
图 2-12 。通过函数移动的有效unique_ptr
实例
使用 shared_ptr 实例
一个unique_ptr
可以给你一个对象的单独所有权,你可以在一个指针实例中移动,一个shared_ptr
可以给你一个对象的共享所有权。这是通过让一个shared_ptr
存储一个内部引用计数以及指向该对象的指针来实现的,只有当所有的值都超出范围时才删除该对象。清单 2-35 展示了一个shared_
ptr
的用法。
清单 2-35 。使用shared_ptr
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
string m_Name;
int m_Value;
public:
MyClass(const string& name, int value)
: m_Name{ name }
, m_Value{ value }
{
cout << "Constructing!" << endl;
}
~MyClass()
{
cout << "Destructing!" << endl;
}
const string& GetName() const
{
return m_Name;
}
int GetValue() const
{
return m_Value;
}
};
using MySharedPtr = shared_ptr<MyClass>;
auto PassSharedPtr(MySharedPtr ptr)
{
cout << "In Function Name: " << ptr->GetName() << endl;
return ptr;
}
int main()
{
auto sharedPointer = make_shared<MyClass>("MyClass", 10);
{
auto newSharedPointer = PassSharedPtr(sharedPointer);
if (sharedPointer)
{
cout << "First Object Name: " << sharedPointer->GetName() << endl;
}
cout << "Second Object Name: " << newSharedPointer->GetName() << endl;
}
return 0;
}
清单 2-35 中的shared_ptr
与您之前看到的unique_ptr
有所不同。一个shared_ptr
可以通过你的程序被复制,你可以有多个指针指向同一个对象。这可以在图 2-13 中看到,这里可以看到第一个对象名语句的输出。
图 2-13 。使用shared_ptr
使用弱指针
现代 C++ 也允许你持有智能指针的弱引用。只要共享对象存在,您就可以在需要时临时获取指向共享对象的指针的引用。清单 2-36 展示了如何使用weak_
ptr
来实现这一点。
清单 2-36 。使用弱指针
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
string m_Name;
int m_Value;
public:
MyClass(const string& name, int value)
: m_Name{ name }
, m_Value{ value }
{
cout << "Constructing!" << endl;
}
~MyClass()
{
cout << "Destructing!" << endl;
}
const string& GetName() const
{
return m_Name;
}
int GetValue() const
{
return m_Value;
}
};
using MySharedPtr = shared_ptr<MyClass>;
using MyWeakPtr = weak_ptr<MyClass>;
auto PassSharedPtr(MySharedPtr ptr)
{
cout << "In Function Name: " << ptr->GetName() << endl;
return ptr;
}
int main()
{
MyWeakPtr weakPtr;
{
auto sharedPointer = make_shared<MyClass>("MyClass", 10);
weakPtr = sharedPointer;
{
auto newSharedPointer = PassSharedPtr(sharedPointer);
if (sharedPointer)
{
cout << "First Object Name: " << sharedPointer->GetName() << endl;
}
cout << "Second Object Name: " << newSharedPointer->GetName() << endl;
auto sharedFromWeak1 = weakPtr.lock();
if (sharedFromWeak1)
{
cout << "Name From Weak1: " << sharedFromWeak1->GetName() << endl;
}
}
}
auto sharedFromWeak2 = weakPtr.lock();
if (!sharedFromWeak2)
{
cout << "Shared Pointer Out Of Scope!" << endl;
}
return 0;
}
你可以在清单 2-36 中看到,一个weak_ptr
可以被分配一个shared_ptr
,但是你不能通过弱指针直接访问共享对象。相反,弱指针提供了一个lock
方法。lock 方法返回一个指向您正在引用的对象的shared_ptr
实例。如果 shared_ptr 最终成为指向该对象的最后一个对象,那么它会在整个作用域内保持该对象的活动状态。lock
方法总是返回一个shared_ptr
,但是如果对象不再存在,lock 返回的shared_ptr
将无法通过if
测试。你可以在删除对象后调用lock
的主函数的末尾看到这一点。图 2-14 显示发生这种情况后weak_ptr
无法获得有效的shared_ptr
。
图 2-14 。未能lock
一个被删除的对象
三、处理文本
处理文本将是 C++ 程序员必须处理的最常见的任务之一。您可能需要读入用户输入,向用户写出消息,或者为其他程序员编写日志功能,以便更容易地调试正在运行的程序。不幸的是,处理文本不是一件容易或直接的任务。程序员太经常地匆忙工作,在他们的文本处理中犯根本性的错误,这些错误后来成为他们项目中的主要问题。最糟糕的是没有正确考虑文本字符串的本地化版本。使用英文字符集通常很容易,因为所有英文字符和标点符号都适合 ASCII 字符集。这很方便,因为表示英语所需的每个字符都可以放入一个 8 位的char
变量中。一旦你的程序被要求支持外语,事情就变得混乱了。您需要支持的每个字符将不再适合单一的 8 位值。C++ 可以用多种方式处理非英语语言,我将在本章中介绍。
配方 3-1。使用文本在代码中表示字符串
问题
在调试程序时,提供输出文本通常很有用。为了做到这一点,C++ 允许你将字符串直接嵌入到你的代码中。
解决办法
C++ 程序有一个称为字符串表的概念,程序中的所有字符串都包含在程序的可执行文件中。
它是如何工作的
标准的 C++ 字符串很容易使用。清单 3-1 显示了创建一个字符串文字的代码。
清单 3-1 。字符串文字
#include <iostream>
#include <string>
using namespace std;
namespace
{
const std::string STRING{ "This is a string"s };
}
int main()
{
cout << STRING << endl;
return 0;
}
本例中的字符串文字是包含在引号内的句子,后跟字母 s。编译器将在编译期间创建一个字符串表,并将它们放在一起。你可以在从图 3-1 中的源创建的 exe 文件中看到这个字符串。
图 3-1 。来自 HxD 的屏幕截图显示了嵌入到可执行文件中的字符串文字
您可以使用字符串来初始化 STL 字符串对象。编译器会找到程序中的所有字符串,并使用字符串表中的地址来初始化字符串。你可以在清单 3-1 中看到这一点,其中指针字符串是使用字符串文字初始化的,实际上这段代码是告诉编译器将文字添加到字符串表中,并从表中获取这个特定字符串的地址,将其传递给string
构造函数。
清单 3-1 中的字符串文字是一个 C++14 风格的字符串文字。旧样式的字符串文字必须小心使用,因为它们带有一些警告。首先,你不应该试图改变字符串的内容。考虑清单 3-2 中的代码。
清单 3-2 。编辑字符串文字
#include <iostream>
using namespace std;
namespace
{
const char* const STRING{ "This is a string" };
char* EDIT_STRING{ "Attempt to Edit" };
}
int main()
{
cout << STRING << endl;
cout << EDIT_STRING << endl;
EDIT_STRING[0] = 'a';
cout << EDIT_STRING << endl;
return 0;
}
清单 3-2 添加了一个新的字符串文字,它被分配给一个非常数指针。main
函数也有试图将字符串中的第一个字符编辑成小写字母 a 的代码。该代码将编译无误,但是您应该会收到来自 C++11/C++14 编译器的警告,因为试图使用数组操作符改变字符串是完全合法的。但是,试图改变字符串中包含的数据是一个运行时异常。尝试运行该程序会导致如图图 3-2 所示的错误。
图 3-2 。试图改变字符串文字时产生运行时错误
通过遵循一条非常简单的建议,您可以在编译时而不是运行时发现这些错误。总是给类型const char* const
的变量分配旧式的字符串文字。如果你想以一种非常直接的方式实施,你可以使用清单 3-3 中的 makefile。
清单 3-3 。编译时将警告作为错误
main: main.cpp
clang++ -Werror -std=c++1y main.cpp -o main
用清单 3-3 中的 makefile 编译你的程序将确保编译器不能用非常数字符串来构建你的应用程序。你可以在图 3-3 中看到一个输出的例子。
图 3-3 。使用–Werror 和可写字符串进行编译时出现错误输出
字符串文字引起的第二个问题是它们增加了程序的大小。在数字世界中,减少程序的下载量是帮助增加软件安装数量的一个关键目标。删除不必要的字符串是减小可执行文件大小的一种方法。清单 3-4 展示了如何使用预处理器来实现这一点。
清单 3-4 。从构建中移除调试字符串
#include <iostream>
#include <string>
using namespace std;
#define DEBUG_STRING_LITERALS !NDEBUG
namespace
{
#if DEBUG_STRING_LITERALS
using StringLiteral = string;
#endif
StringLiteral STRING{ "This is a String!"s };
}
int main()
{
cout << STRING << endl;
return 0;
}
清单 3-4 使用NDEBUG
符号创建一个预处理器符号DEBUG_STRING_LITERALS
。NDEBUG
预处理器符号代表不调试,因此我们可以用它来决定我们是否想要在程序中包含调试字符串。然后,类型别名StringLiteral
的定义被包装在#if...#endif
块中,确保StringLiteral
只在构建调试版本时存在。NDEBUG
符号通常在 ide 中构建程序的发布版本时使用。由于本书附带的示例是使用 make 构建的,因此您必须在 makefile 中手动定义它。清单 3-5 中显示了一个 makefile 示例。
清单 3-5 。定义 NDEBUG 的 makefile
main: main.cpp
clang++ -D NDEBUG -O2 -Werror -std=c++1y main.cpp -o main
此时,您还需要包装创建或使用任何StringLiteral
类型变量的任何代码。这时你应该会发现一个问题,使用这个 define 意味着你的程序中不能有任何字符串。清单 3-6 给出了一个更好的解决方案。
清单 3-6 。分离调试和非调试字符串文字
#include <iostream>
#include <string>
using namespace std;
#define DEBUG_STRING_LITERALS !NDEBUG
namespace
{
#if DEBUG_STRING_LITERALS
using DebugStringLiteral = string;
#endif
#if DEBUG_STRING_LITERALS
DebugStringLiteral STRING{ "This is a String!"s };
#endif
}
int main()
{
#if DEBUG_STRING_LITERALS
cout << STRING << endl;
#endif
return 0;
}
对诊断代码使用调试文字,如清单 3-6 所示,最终用户永远不会看到,这允许您删除字符串和代码,从而减少可执行文件的大小并提高执行速度。
食谱 3-2。本地化面向用户的文本
问题
你永远不知道什么时候你可能需要支持一种除了你母语之外的语言。确保用户可以看到的任何字符串都来自本地化的源。
解决办法
构建一个字符串管理器类,它从自己创建的表中返回字符串,并且只引用使用 id 的字符串。
它是如何工作的
通过使用您在源代码中定义为字符串文字的字符串与用户通信,您可以合法地对整个项目进行编码。这有几个主要缺点。首先,很难即时切换语言。今天,你的软件很可能会通过互联网发布。你的程序不被和你说不同语言的人使用的可能性非常小。在大型开发团队中,开发团队中的人有可能拥有不同的第一语言。从一开始就在你的程序中建立本地化文本的能力将会在以后为你省去很多麻烦。这是通过从文件中为你的程序加载字符串数据来实现的。然后,您可以在数据中包含多种不同的语言,方法是用您的母语编写字符串,并让朋友或翻译服务为您将字符串翻译成其他语言。
您需要创建一个类来处理游戏的本地化字符串内容。清单 3-7 显示了本地化管理器的类定义。
清单 3-7 。本地化经理
#pragma once
#include <array>
#include <cinttypes>
#include <string>
#include <unordered_map>
namespace Localization
{
using StringID = int32_t;
enum class Languages
{
EN_US,
EN_GB,
Number
};
const StringID STRING_COLOR{ 0 };
class Manager
{
private:
using Strings = std::unordered_map<StringID, std::string>;
using StringPacks =
std::array<Strings, static_cast<size_t>(Languages::Number)>;
StringPacks m_StringPacks;
Strings* m_CurrentStringPack{ nullptr };
uint32_t m_LanguageIndex;
public:
Manager();
void SetLanguage(Languages language);
std::string GetString(StringID stringId) const;
};
}
清单 3-7 中做了很多事情。源代码需要注意的第一个方面是名称空间。如果您在名称空间中保存不同的类,并且这些类的名称有意义,那么您会发现管理代码会更容易。对于本地化模块,我使用了名称Localization
。当你使用这个模块中的类和对象时,这将有助于在你的代码中清楚地表达出来。
创建了一个类型别名作为不同字符串的标识符。同样,类型别名在这里很有用,因为您可能会在将来的某个时候决定更改字符串 id 的类型。有一个enum class
决定了本地化管理器支持的语言。StringID STRING_COLOR
被定义为 0。这是本例中唯一的StringID
,因为它是我们说明本地化管理器如何操作所需要的全部内容。
Manager
本身定义了一些私有类型别名来使代码清晰。有一个定义的别名允许我们创建一个unordered_map of StringID to std::string
对,另一个允许创建这些字符串映射的数组。还声明了一个变量来实例化一个字符串映射数组以及一个指向当前正在使用的字符串映射的指针。这个类有一个构造函数和另外两个方法,SetLanguage
和GetString
。清单 3-8 显示了构造函数的源代码。
清单 3-8 。本地化::管理器构造函数
Manager::Manager()
{
static const uint32_t INDEX_EN_US{ static_cast<uint32_t>(Languages::EN_US) };
m_StringPacks[INDEX_EN_US][STRING_COLOR] = "COLOR"s;
static const uint32_t INDEX_EN_GB{ static_cast<uint32_t>(Languages::EN_GB) };
m_StringPacks[INDEX_EN_GB][STRING_COLOR] = "COLOUR"s;
SetLanguage(Languages::EN_US);
}
这个基本构造函数初始化两个字符串映射,一个用于美国英语,一个用于英国英语。您可以看到单词 color 的不同拼写被传递到每个地图中。源代码的最后一行将默认语言设置为美国英语。SetLanguage
方法如清单 3-9 所示。
清单 3-9 。本地化::Manager::SetLanguage
void Manager::SetLanguage(Languages language)
{
m_CurrentStringPack = &(m_StringPacks[static_cast<uint32_t>(language)]);
}
这个方法很简单。它只是设置m_CurrentStringPack
变量来存储所选语言的字符串映射的地址。你必须static_cast
枚举类型变量,因为 C++ 的 STL 数组不允许你使用非数值类型的索引。您可以看到static_cast
正在将语言参数转换为uint32_t
。
Manager
类中的最后一个方法是GetString
方法,你可以在清单 3-10 中看到。
清单 3-10 。本地化::Manager::GetString
std::string Manager::GetString(StringID stringId) const
{
stringstream resultStream;
resultStream << "!!!"s;
resultStream << stringId;
resultStream << "!!!"s;
string result{ resultStream.str() };
auto iter = m_CurrentStringPack->find(stringId);
if (iter != m_CurrentStringPack->end())
{
result = iter->second;
}
return result;
}
GetString
方法从构建从函数返回的默认字符串开始。这将允许您打印出程序中任何丢失的字符串 id,以帮助本地化测试工作。然后使用unordered_map::find
方法在映射中搜索字符串 id。如果返回一个有效的iterator
,你就知道find
调用是否成功。如果搜索没有找到匹配,它将返回end iterator
。if
语句检查是否在映射中找到了字符串 id。如果找到了,给定 id 的字符串将存储在result
变量中,并传递回方法调用方。
注意你可以让缺省的缺失字符串只发生在非最终版本中。这将节省在最终用户的计算机上构建这个字符串的执行成本。他们应该希望永远不会在他们的程序中看到丢失的字符串。
清单 3-11 列出了一个更新的main
函数,展示了如何在你的代码中使用这个Manager
。
清单 3-11 。使用Localization::Manager class
#include <iostream>
#include "LocalizationManager.h"
using namespace std;
int main()
{
Localization::Manager localizationManager;
string color{ localizationManager.GetString(Localization::STRING_COLOR) };
cout << "EN_US Localized string: " << color.c_str() << endl;
localizationManager.SetLanguage(Localization::Languages::EN_GB);
color = localizationManager.GetString(Localization::STRING_COLOR);
cout << "EN_GB Localized string: " << color.c_str() << endl;
color = localizationManager.GetString(1);
cout << color.c_str() << endl;
return 0;
}
main
函数现在创建了一个Localization::Manager
类的实例。您可以看到一个如何从管理器中检索字符串并使用cout
将其输出的示例。然后,语言被切换到英国英语,字符串被检索并被第二次打印。为了完整起见,最后一个例子显示了当您请求一个不存在的字符串 id 时会发生什么。图 3-4 包含程序的输出。
图 3-4 。本地化管理器字符串的输出
下图显示了您所期望的输出。首先显示颜色的美国英语拼写,然后是英国英语拼写,最后输出缺少的 id,在开头和结尾有三个感叹号。这将有助于突出程序中缺少的字符串标识符。
食谱 3-3。从文件中读取字符串
问题
在源代码中嵌入面向用户的文本会使将来的文本更新和本地化难以管理。
解决办法
您可以从数据文件中加载本地化字符串数据。
它是如何工作的
我将向您展示如何将字符串数据从逗号分隔的值(csv)文件。在加载这样的文件之前,您需要创建一个。图 3-5 显示了我输入 Excel 导出为. csv 文件的数据。
图 3-5 。Excel 2013 中的字符串. csv 文件
我已经用 Excel 创建了一个非常基本的。csv 文件。你可以看到我在上一节中使用的颜色和颜色值,以及美国和英国风味的拼写。图 3-6 显示了该文件如何出现在基本文本编辑器中。
图 3-6 。strings.csv 文件在 Notepad++ 中打开
Excel 文档中的每一行都放在 csv 文件中自己的行中,每一列都用逗号分隔。这也是 csv 得名的原因。现在我们有了一个 csv 文件,我们可以将数据加载到Localization::Manager
的构造函数中。清单 3-12 包含代码,可用于加载和解析字符串 csv 文件。
清单 3-12 。从 csv 加载字符串
Manager::Manager()
{
ifstream csvStringFile{ "strings.csv"s };
assert(csvStringFile);
if (csvStringFile)
{
while (!csvStringFile.eof())
{
string line;
getline(csvStringFile, line);
if (line.size() > 0)
{
// Create a stringstream for the line
stringstream lineStream{ line };
// Use the line stream to read in the string id
string stringIdText;
getline(lineStream, stringIdText, ',');
stringstream idStream{ stringIdText };
uint32_t stringId;
idStream >> stringId;
// Loop over the line and read in each string
uint32_t languageId = 0;
string stringText;
while (getline(lineStream, stringText, ','))
{
m_StringPacks[languageId++][stringId] = stringText;
}
}
}
}
SetLanguage(Languages::EN_US);
}
在 strings.csv 文件中读取的代码并不太复杂。第一步是打开文件进行读取,代码使用一个ifstream
对象来实现。C++ 提供了ifstream
类来从文件中读取数据,并提供了实现这一点的方法。我们使用的第一个方法是重载指针操作符。当我们使用assert
或if
来确定传入ifstream
的文件是否有效并被打开时,就会调用这个函数。接下来是一个 while 循环,它将一直运行到文件结束或者eof
方法返回 true。这是理想的,因为我们不希望在所有的字符串都被加载之前停止读取数据。
ifstream
类提供了一个getline
方法,可以用于 C 风格的字符串数组。一般来说,使用std::string
比使用原始的 C 字符串更好,更不容易出错,所以在清单 3-12 中,你可以看到std::getline
方法的使用,它引用任何类型的流。getline 的第一个用途是将 csv 文件中的一整行文本检索到一个std::string
对象中。这一行包含关于单个字符串的数据,以其 id 开始,后面是文本的每个本地化版本。
s td::getline
方法有一个非常有用的第三个参数。默认情况下,该方法从文件中检索文本,直到它到达一个换行符,但是我们可以传入一个不同的字符作为第三个参数,当遇到这个字符时,该函数将停止收集文本。清单 3-11 通过传入一个逗号作为分隔符来利用这个特性。这允许我们从 Excel 文档的每个单元格中提取值。
getline
函数需要一个流对象传递给它,但是该行被读入到std::string
中。你可以看到这个问题是通过创建一个stringstream
对象并将 line 变量传递给构造函数来解决的。一旦创建了 stringstream,就使用 getline 方法通过一个stringstream
对象检索字符串 id。
注 C++ 提供了几种将字符串转换成数值的方法。这些包括转换成整数的
stoi
和转换成浮点数的stof
以及其他。这些都在字符串头文件中定义。你还可以在那里找到一个函数named to_string
,它可以用来将几种不同的类型转换成一个字符串。您可能正在使用的 STL 的实现并不总是提供这些。例如,Cygwin 中当前可用的 libstdc++ 版本不提供这些函数,因此代码示例没有使用它们。
在该方法检索到 id 之后,它循环遍历该行的其余部分,并读出每种语言的字符串数据。这依赖于Languages enum class
定义的语言顺序与 csv 文件中的列顺序相同。
配方 3-4。从 XML 文件中读取数据
问题
虽然 CSV 文件是一种非常简单的格式,对于某些应用程序来说非常好,但是它们有一个主要的缺陷;用逗号分隔字符串意味着不能在字符串数据中使用逗号,因为加载代码会将它们解释为字符串的结尾。如果发生这种情况,代码可能会崩溃,因为它试图读入太多的字符串,使数组溢出。
解决办法
将字符串文件保存为 XML 文档,并使用解析器加载数据。
它是如何工作的
RapidXML 库是一个开源的 XML 解决方案,可以用于您的 C++ 应用程序。它以头文件的形式提供,可以包含在任何需要 XML 处理能力的源文件中。您可以从以下位置http://rapidxml.sourceforge.net/
下载 RapidXML 的最新版本。我使用 XML Spreadsheet 2003 文件类型保存了我的 Excel 文档。本节中显示的代码能够加载这种类型的 XML 文件。清单 3-13 显示了包含我们的字符串数据的整个文件。
清单 3-13 。XML 电子表格文件
<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:html="http://www.w3.org/TR/REC-html40">
<DocumentProperties >
272103_1_EnBruce Sutherland</Author>
<LastAuthor>Bruce</LastAuthor>
<Created>2014-06-13T06:29:44Z</Created>
<Version>15.00</Version>
</DocumentProperties>
<OfficeDocumentSettings >
<AllowPNG/>
</OfficeDocumentSettings>
<ExcelWorkbook >
<WindowHeight>12450</WindowHeight>
<WindowWidth>28800</WindowWidth>
<WindowTopX>0</WindowTopX>
<WindowTopY>0</WindowTopY>
<ProtectStructure>False</ProtectStructure>
<ProtectWindows>False</ProtectWindows>
</ExcelWorkbook>
<Styles>
<Style ss:ID="Default" ss:Name="Normal">
<Alignment ss:Vertical="Bottom"/>
<Borders/>
<Font ss:FontName="Calibri" x:Family="Swiss" ss:Size="11" ss:Color="#000000"/>
<Interior/>
<NumberFormat/>
<Protection/>
</Style>
</Styles>
<Worksheet ss:Name="strings">
<Table ss:ExpandedColumnCount="3" ss:ExpandedRowCount="2" x:FullColumns="1"
x:FullRows="1" ss:DefaultColumnWidth="54" ss:DefaultRowHeight="14.25">
<Row>
<Cell><Data ss:Type="Number">0</Data></Cell>
<Cell><Data ss:Type="String">Color</Data></Cell>
<Cell><Data ss:Type="String">Colour</Data></Cell>
</Row>
<Row>
<Cell><Data ss:Type="Number">1</Data></Cell>
<Cell><Data ss:Type="String">Flavor</Data></Cell>
<Cell><Data ss:Type="String">Flavour</Data></Cell>
</Row>
</Table>
<WorksheetOptions >
<PageSetup>
<Header x:Margin="0.3"/>
<Footer x:Margin="0.3"/>
<PageMargins x:Bottom="0.75" x:Left="0.7" x:Right="0.7" x:Top="0.75"/>
</PageSetup>
<Selected/>
<ProtectObjects>False</ProtectObjects>
<ProtectScenarios>False</ProtectScenarios>
</WorksheetOptions>
</Worksheet>
</Workbook>
从这个文件清单中,您可能会发现我们的解析代码需要忽略大量的数据。从文档根开始,我们将通过工作簿节点访问字符串数据,然后是工作表、表、行、单元格,最后是数据节点。
注意这种 XML 数据格式非常冗长,不必要的数据有点多。使用 Excel 的 Visual Basic for Applications 宏支持编写自己的轻量级导出器会更好,但这个主题超出了本书的范围。
清单 3-14 涵盖了使用 RapidXML 加载字符串数据所需的代码。
清单 3-14 。使用 RapidXML 加载字符串
Manager::Manager()
{
ifstream xmlStringFile{ "strings.xml"s };
xmlStringFile.seekg(0, ios::end);
uint32_t size{ static_cast<uint32_t>(xmlStringFile.tellg()) + 1 };
char* buffer{ new char[size]{} };
xmlStringFile.seekg(0, ios::beg);
xmlStringFile.read(buffer, size);
xmlStringFile.close();
rapidxml::xml_document<> document;
document.parse<0>(buffer);
rapidxml::xml_node<>* workbook{ document.first_node("Workbook") };
if (workbook != nullptr)
{
rapidxml::xml_node<>* worksheet{ workbook->first_node("Worksheet") };
if (worksheet != nullptr)
{
rapidxml::xml_node<>* table{ worksheet->first_node("Table") };
if (table != nullptr)
{
rapidxml::xml_node<>* row{ table->first_node("Row") };
while (row != nullptr)
{
uint32_t stringId{ UINT32_MAX };
rapidxml::xml_node<>* cell{ row->first_node("Cell") };
if (cell != nullptr)
{
rapidxml::xml_node<>* data{ cell->first_node("Data") };
if (data != nullptr)
{
stringId = static_cast<uint32_t>(atoi(data->value()));
}
}
if (stringId != UINT32_MAX)
{
uint32_t languageIndex{ 0 };
cell = cell->next_sibling("Cell");
while (cell != nullptr)
{
rapidxml::xml_node<>* data = cell->first_node("Data");
if (data != nullptr)
{
m_StringPacks[languageIndex++][stringId] = data->value();
}
cell = cell->next_sibling("Cell");
}
}
row = row->next_sibling("Row");
}
}
}
}
这个清单有很多内容,所以我将一节一节地分解。第一步是使用下面的代码将 XML 文件的全部内容加载到内存中。
ifstream xmlStringFile{ "strings.xml"s };
xmlStringFile.seekg(0, ios::end);
uint32_t size{ static_cast<uint32_t>(xmlStringFile.tellg()) + 1 };
char* buffer{ new char[size]{} };
xmlStringFile.seekg(0, ios::beg);
xmlStringFile.read(buffer, size);
xmlStringFile.close();
你需要将整个文件存储在一个空终止的内存缓冲区中,这就是为什么使用ifstream
打开文件,然后使用seekg
移动到流的末尾。一旦结束,tellg
方法可以用来计算出文件有多大。来自tellg
的值加 1,以确保有足够的内存分配给 RapidXML 要求的空终止字符。动态内存分配用于在内存中创建缓冲区,而memset
清除整个缓冲区以包含零。seekg
方法用于在 read 用于将文件的全部内容获取到分配的缓冲区之前,将文件流位置移动到文件的开头。最后一步是在代码处理完文件后立即关闭文件流。
这两行负责从文件内容初始化 XML 数据结构。
rapidxml::xml_document<> document;
document.parse<0>(buffer);
这段代码创建了一个包含解析方法的 XML 文档对象。作为模板参数传递的 0 可用于在解析器上设置不同的标志,但本例不需要这些标志。现在代码已经创建了 XML 文档的解析表示,可以开始访问它包含的节点了。接下来的几行检索指向工作簿、工作表、表和行节点的指针。
rapidxml::xml_node<>* workbook{ document.first_node("Workbook") };
if (workbook != nullptr)
{
rapidxml::xml_node<>* worksheet{ workbook->first_node("Worksheet") };
if (worksheet != nullptr)
{
rapidxml::xml_node<>* table{ worksheet->first_node("Table") };
if (table != nullptr)
{
rapidxml::xml_node<>* row{ table->first_node("Row") };
while (row != nullptr)
{
这些线都是直的。在一个简单的 Excel XML 文档中只有一个工作簿、工作表和表格,因此我们可以简单地向每个节点请求该名称的第一个子节点。一旦代码到达行元素,就会有一个 while 循环。这将允许我们检查电子表格中的每一行,并将我们的字符串加载到适当的映射中。整行 while 循环如下。
rapidxml::xml_node<>* row{ table->first_node("Row") };
while (row != nullptr)
{
uint32_t stringId{ UINT32_MAX };
rapidxml::xml_node<>* cell{ row->first_node("Cell") };
if (cell != nullptr)
{
rapidxml::xml_node<>* data{ cell->first_node("Data") };
if (data != nullptr)
{
stringId = static_cast<uint32_t>(atoi(data->value()));
}
}
if (stringId != UINT32_MAX)
{
uint32_t languageIndex{ 0 };
cell = cell->next_sibling("Cell");
while (cell != nullptr)
{
rapidxml::xml_node<>* data = cell->first_node("Data");
if (data != nullptr)
{
m_StringPacks[languageIndex++][stringId] = data->value();
}
cell = cell->next_sibling("Cell");
}
}
row = row->next_sibling("Row");
}
while
循环从从第一个单元和数据节点获取stringId
开始。atoi
函数用于将 C 风格的字符串转换成一个必须转换成unsigned int
的整数。下面的if
检查是否获得了有效的字符串 id,如果是,则代码进入另一个while
循环。这个循环从后续的单元格和数据节点中获取每个字符串,并将它们放入正确的映射中。它通过最初将语言索引设置为 0,并在输入每个字符串后递增索引来实现这一点。这再次要求将本地化的字符串以正确的顺序输入到电子表格中。
这就是从 XML 文件加载字符串数据所需的全部内容。您应该能够找到一种更好的方法来生成这些文件,而不会消耗太多的数据。您可能还会遇到这样的情况:加载所有文本会消耗太多系统内存。此时,您应该考虑将每种语言拆分到一个单独的文件中,并且只在需要时才加载这些语言。用户不太可能需要您选择支持的每一种翻译语言。
配方 3-5。将运行时数据插入字符串
问题
有时,您需要在字符串中输入运行时数据,如数字或用户名。虽然 C++ 支持旧的 C 函数来格式化 C 风格的字符串,但是这些函数不能处理 STL 的string
类。
解决办法
boost 库为 C++ 提供了广泛的库支持,包括用于格式化 STL 字符串中保存的数据的方法和函数。
它是如何工作的
首先,您应该在电子表格中添加一个包含以下数据的新行;2, %1% %2%, %2% %1%.您应该将逗号后面的每个元素放在新的单元格中。清单 3-15 更新了 main 函数以利用这个新的字符串。
清单 3-15 。使用boost::format
#include <iostream>
#include "LocalizationManager.h"
#include "boost/format.hpp"
using namespace std;
int main()
{
Localization::Manager localizationManager;
std::string color{ localizationManager.GetString(Localization::STRING_COLOR) };
std::cout << "EN_US Localized string: " << color.c_str() << std::endl;
std::string flavor{ localizationManager.GetString(Localization::STRING_FLAVOR) };
std::cout << "EN_US Localized string: " << flavor.c_str() << std::endl;
localizationManager.SetLanguage(Localization::Languages::EN_GB);
color = localizationManager.GetString(Localization::STRING_COLOR);
std::cout << "EN_GB Localized string: " << color.c_str() << std::endl;
flavor = localizationManager.GetString(Localization::STRING_FLAVOR);
std::cout << "EN_GB Localized string: " << flavor.c_str() << std::endl;
color = localizationManager.GetString(3);
std::cout << color.c_str() << std::endl;
std::cout << "Enter your first name: " << std::endl;
std::string firstName;
std::cin >> firstName;
std::cout << "Enter your surname: " << std::endl;
std::string surname;
std::cin >> surname;
localizationManager.SetLanguage(Localization::Languages::EN_US);
std::string formattedName{ localizationManager.GetString(Localization::STRING_NAME) };
formattedName = str( boost::format(formattedName) % firstName % surname );
std::cout << "You said your name is: " << formattedName << std::endl;
localizationManager.SetLanguage(Localization::Languages::EN_GB);
formattedName = localizationManager.GetString(Localization::STRING_NAME);
formattedName = str(boost::format(formattedName) % firstName % surname);
std::cout << "You said your name is: " << formattedName << std::endl;
return 0;
}
你可以看到在清单 3-15 中添加的main
附件要求用户输入他们自己的名字。对cin
的调用将停止程序执行,直到用户输入他们的名字和姓氏。一旦程序存储了用户名,它就将语言更改为 EN_US,并从本地化管理器获取字符串。下一行使用 b oost::format
函数将字符串中的符号替换为firstName
和surname
值。我们的新字符串包含符号%1%和%2%。这用于决定将哪些变量替换到字符串中。对format
的调用后跟一个%操作符,然后是firstName
字符串。因为 firstName 是传递给%运算符的第一个参数,所以它将替换字符串中的%1%。类似地,姓氏将用于替换%2%,因为它是使用%传递的第二个参数。
这都是因为format
函数正在设置一个从format
函数返回的对象。然后这个对象被传递给它的%
操作符,后者将值存储在firstName
中。对操作符%
的第一次调用返回对 boost format
对象的引用,该对象被传递给对操作符%
的第二次调用。直到 format 对象被传递到str
函数中,源字符串中的符号才被真正解析。Boost 在全局名称空间中声明了str
函数,因此它不需要名称空间范围操作符。str
方法获取格式对象并构造一个新的string
,将参数替换到适当的位置。当您将源字符串输入到电子表格中时,EN_GB 字符串的名称被调换了。你可以在图 3-7 中看到代码的结果。
图 3-7 。来自boost::format
的输出
您可以使用boost::format
将各种数据替换成字符串。不幸的是,boost 并不遵循与标准 C printf
函数相同的约定,因此你需要对标准 C 程序使用不同的字符串。boost 提供的格式化选项的完整列表可以在http://www.boost.org/doc/libs/1_55_0/libs/format/doc/format.html
找到。
在程序中包含 boost/format.hpp 头文件所需的 makefile 相对来说比较简单。你可以在清单 3-16 中看到。
清单 3-16 。包括 Boost 库
main: main.cpp LocalizationManager.cpp
clang++ -g -std=c++1y -Iboost_1_55_0 main.cpp LocalizationManager.cpp -o main
您可以从这个 makefile 中看到,我使用的是 1.55 版本的 Boost 库,并且我将该文件夹放在了与我的 makefile 相同的文件夹中。包含 Boost 头文件的约定是在 include 指令中命名 Boost 文件夹,因此 clang++ 命令中的–I 开关只是告诉编译器查看 boost_1_55_0 文件夹内部。boost 文件夹位于该文件夹中。
四、处理数字
计算机是为处理数字而设计和制造的。您编写的程序将利用计算机的计算能力,为完全依赖于您理解和利用 C++ 提供的工具来处理数字的用户提供体验。C++ 支持不同类型的数字,这种支持包括整数和实数,以及多种不同的存储和表示方式。
C++ 整数类型将用于存储整数,浮点类型将用于存储带小数点的实数。在 C++ 中使用每种类型的数字时,都有不同的权衡和考虑,本章将向您介绍每种类型适用的不同挑战和场景。您还将看到一种更古老的技术,称为定点算术,它可以使用整数类型来近似浮点类型。
配方 4-1。在 C++ 中使用整数类型
问题
您需要在程序中表示整数,但是不确定不同整数类型的限制和能力。
解决办法
了解 C++ 支持的不同整数类型,将允许您为手头的任务使用正确的类型。
它是如何工作的
使用 int 类型
C++ 提供了现代处理器支持的不同整数类型的精确表示。所有整数类型的行为方式完全相同,但是它们可能包含比彼此更多或更少的数据。清单 4-1 展示了如何在 C++ 中定义一个整数变量。
清单 4-1 。定义整数
int main(int argc, char* argv[])
{
int wholeNumber{ 64 };
return 0;
}
如你所见,在 C++ 中整数是用int
类型定义的。C++ 中的int
类型可以与标准算术运算符结合使用,这些运算符允许您进行加、减、乘、除和取模运算。清单 4-2 使用这些操作符来初始化额外的整数变量。
清单 4-2 。使用运算符初始化整数
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
int wholeNumber1{ 64 };
cout << "wholeNumber1 equals " << wholeNumber1 << endl;
int wholeNumber2{ wholeNumber1 + 32 };
cout << "wholeNumber2 equals " << wholeNumber2 << endl;
int wholeNumber3{ wholeNumber2 - wholeNumber1 };
cout << "wholeNumber3 equals " << wholeNumber3 << endl;
int wholeNumber4{ wholeNumber2 * wholeNumber1 };
cout << "wholeNumber4 equals " << wholeNumber4 << endl;
int wholeNumber5{ wholeNumber4 / wholeNumber1 };
cout << "wholeNumber5 equals " << wholeNumber5 << endl;
int wholeNumber6{ wholeNumber4 % wholeNumber1 };
cout << "wholeNumber6 equals " << wholeNumber6 << endl;
return 0;
}
清单 4-2 中的代码包含使用操作符初始化额外整数的行。运算符有多种用法。您可以看到,操作符的两边可以有文字值(如 32)或其他变量。图 4-1 显示了该程序的输出。
图 4-1 。运行清单 4-2 中代码的输出
清单 4-2 的输出如图图 4-1 所示。下面的列表解释了输出中显示的值如何出现在每个变量中。
- 变量
wholeNumber1
用值 64 初始化,因此输出是 64。 - 文字 32 被加到
wholeNumber1
的值上,并存储在wholeNumber2
中,因此输出在 96 中。 - 下一行输出 32,因为代码已经从
wholeNumber1
中减去了wholeNumber2
。这样做的效果是,我们成功地将初始化wholeNumber2
得到的文字值存储在变量wholeNumber3
中。 wholeNumber4
的值输出为 64*96 的结果 6144。- 程序打印出
wholeNumber5
的值 96,因为它是 6144 除以 64 的结果,或者是wholeNumber4
的值除以wholeNumber1
的值。 wholeNumber6
的值输出为 32。模运算符返回除法的余数。在这种情况下,96/64 的余数是 32,因此模运算符返回 32。
使用不同类型的整数
C++ 编程语言支持不同类型的整数。表 4-1 显示了不同类型的整数及其属性。
表 4-1 。C++ 整数类型
表 4-1 列出了 C++ 提供的处理整数的五种主要类型。C++ 提出的问题是,这些类型并不总是保证表示如表 4-1 所示的字节数。这是因为 C++ 标准将多少字节表示的决定权留给了平台。这种情况不完全是 C++ 的错。处理器制造商可以选择使用不同的字节数来表示整数,因此这些平台的编译器作者可以根据标准自由地改变类型以适合他们的处理器。然而,您可以通过使用cinttypes
头来编写保证整数中字节数的代码。表 4-2 显示了通过cinttypes
可获得的不同整数。
表 4-2 。cinttypes
整数
由cinttypes
提供的类型包含它们所代表的位数。假设一个字节中有 8 位,你可以在表 4-2 中看到类型和字节数的关系。清单 4-3 使用与清单 4-2 相同的操作符,但更新后使用int32_t
类型代替int
。
清单 4-3 。使用带有运算符的int32_t
类型
#include <iostream>
#include <cinttypes>
using namespace std;
int main(int argc, char* argv[])
{
int32_t whole32BitNumber1{ 64 };
cout << "whole32BitNumber1 equals " << whole32BitNumber1 << endl;
int32_t whole32BitNumber2{ whole32BitNumber1 + 32 };
cout << "whole32BitNumber2 equals " << whole32BitNumber2 << endl;
int32_t whole32BitNumber3{ whole32BitNumber2 - whole32BitNumber1 };
cout << "whole32BitNumber3 equals " << whole32BitNumber3 << endl;
int32_t whole32BitNumber4{ whole32BitNumber2 * whole32BitNumber1 };
cout << "whole32BitNumber4 equals " << whole32BitNumber4 << endl;
int32_t whole32BitNumber5{ whole32BitNumber4 / whole32BitNumber1 };
cout << "whole32BitNumber5 equals " << whole32BitNumber5 << endl;
int whole32BitNumber6{ whole32BitNumber2 % whole32BitNumber1 };
cout << "whole32BitNumber6 equals " << whole32BitNumber6 << endl;
return 0;
}
从图 4-2 中可以看到,该代码产生的输出类似于图 4-1 的输出。
图 4-2 。使用清单 4-2 中的int32_t
和代码时的输出
使用无符号整数
在表 4-1 和表 4-2 中显示的每种类型都有无符号的对应物。使用该类型的无符号版本意味着您将不再能够访问负数,但是您将拥有由相同字节数表示的更大范围的正数。你可以在表 4-3 中看到 C++ 标准无符号类型。
表 4-3 。C++ 的内置无符号类型
无符号数存储的数字范围与其有符号数相同。一个signed char
和一个unsigned char
都可以存储 256 个唯一值。signed char
存储从-128 到 127 的值,而unsigned
版本存储从 0 到 255 的 256 个值。内置的无符号类型与有符号类型面临同样的问题,它们在不同的平台上可能表示不同的字节数。C++ 的cinttypes
头文件提供了保证其大小的无符号类型。表 4-4 记录了这些类型。
表 4-4 。cintypes 头文件的无符号整数类型
食谱 4-2。用关系运算符做决策
问题
你正在编写一个程序,必须根据两个值的比较结果做出决定。
解决办法
C++ 提供了基于计算的比较返回 true 或 false 的关系运算符。
它是如何工作的
C++ 提供了四种主要的关系运算符 。这些是:
- 相等运算符
- 不等式算子
- 大于号运算符
- 小于运算符
这些运算符允许您快速比较两个值,并确定结果是真还是假。真或假比较的结果可以存储在 C++ 提供的bool
类型中。一个bool
只能代表true
或者false
。
相等运算符
清单 4-4 显示了使用中的等式运算符 。
清单 4-4 。C++ 相等运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
int32_t equal1{ 10 };
int32_t equal2{ 10 };
bool isEqual = equal1 == equal2;
cout << "Are the numbers equal? " << isEqual << endl;
int32_t notEqual1{ 10 };
int32_t notEqual2{ 100 };
bool isNotEqual = notEqual1 == notEqual2;
cout << "Are the numbers equal? " << isNotEqual << endl;
return 0;
}
清单 4-4 中的代码生成如图图 4-3 所示的输出。
图 4-3 。关系等式运算符的输出
如果运算符两边的值相同,等式运算符会将 bool 变量的值设置为 true(在输出中表示为 1)。这是清单 4-4 比较equal1
和equal2
的情况。当两边的值不同时,如代码比较notEqual1
和notEqual2
时,运算符的结果为假。
不等式算子
不等式运算符 用于判断数字何时不相等。清单 4-5 展示了不等式操作符的使用。
清单 4-5 。不等式算子
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
int32_t equal1{ 10 };
int32_t equal2{ 10 };
bool isEqual = equal1 != equal2;
cout << "Are the numbers not equal? " << isEqual << endl;
int32_t notEqual1{ 10 };
int32_t notEqual2{ 100 };
bool isNotEqual = notEqual1 != notEqual2;
cout << "Are the numbers not equal? " << isNotEqual << endl;
return 0;
}
清单 4-5 产生的输出如图图 4-4 所示。
图 4-4 。清单 4-5 的输出显示了不等式运算符的结果
从清单 4-5 和图 4-4 可以看出,当值不相等时不等式运算符将返回 true,当值相等时返回 false。
大于号运算符
大于运算符 可以告诉你左边的数字是否大于右边的数字。清单 4-6 展示了这一点。
清单 4-6 。大于号运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
int32_t greaterThan1{ 10 };
int32_t greaterThan2{ 1 };
bool isGreaterThan = greaterThan1 > greaterThan2;
cout << "Is the left greater than the right? " << isGreaterThan << endl;
int32_t notGreaterThan1{ 10 };
int32_t notGreaterThan2{ 100 };
bool isNotGreaterThan = notGreaterThan1 > notGreaterThan2;
cout << "Is the left greater than the right? " << isNotGreaterThan << endl;
return 0;
}
大于运算符将 bool 的值设置为 true 或 false。当左边的数字大于右边的数字时,结果为真;当右边的数字大于左边的数字时,结果为假。图 4-5 显示了清单 4-6 生成的输出。
图 4-5 。清单 4-6 生成的输出
小于运算符
小于运算符产生与大于运算符相反的结果。当左边的数字小于右边的数字时,小于运算符返回 true。清单 4-7 显示了正在使用的操作符。
清单 4-7 。小于运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
int32_t lessThan1{ 1 };
int32_t lessThan2{ 10 };
bool isLessThan = lessThan1 < lessThan2;
cout << "Is the left less than the right? " << isLessThan << endl;
int32_t notLessThan1{ 100 };
int32_t notLessThan2{ 10 };
bool isNotLessThan = notLessThan1 < notLessThan2;
cout << "Is the left less than the right? " << isNotLessThan << endl;
return 0;
}
图 4-6 显示了执行清单 4-7 中的代码时的结果。
图 4-6 。在清单 4-7 中使用小于运算符时产生的输出
食谱 4-3。用逻辑运算符链接决策
问题
有时,为了将布尔值设置为 true,您的代码需要满足多个条件。
解决办法
C++ 提供了逻辑运算符 ,允许将关系语句链接起来。
它是如何工作的
C++ 提供了两个逻辑操作符,允许链接多个关系语句。这些是:
- && (and)运算符
- ||(或)运算符
&&运算符
当您想要确定两个不同的关系运算符都为真时,可以使用&&运算符 。清单 4-8 显示了正在使用的&操作符。
清单 4-8 。逻辑& &运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
bool isTrue { (10 == 10) && (12 == 12) };
cout << "True? " << isTrue << endl;
bool isFalse = isTrue && (1 == 2);
cout << "True? " << isFalse << endl;
return 0;
}
isTrue
的值被设置为真,因为两个关系运算都产生真值。isFalse
的值被设置为 false,因为两个关系语句都不会产生 true 语句。这些操作的输出可以在图 4-7 中看到。
图 4-7 。&生成的逻辑&运算符输出由列表 4-8
逻辑||运算符
逻辑||运算符用于确定所用的一个或两个语句何时为真。清单 4-9 包含测试||操作符结果的代码。
清单 4-9 。逻辑||运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
bool isTrue { (1 == 1) || (0 == 1) };
cout << "True? " << isTrue << endl;
isTrue = (0 == 1) || (1 == 1);
cout << "True? " << isTrue << endl;
isTrue = (1 == 1) || (1 == 1);
cout << "True? " << isTrue << endl;
isTrue = (0 == 1) || (1 == 0);
cout << "True? " << isTrue << endl;
return 0;
}
该代码生成的结果输出可以在图 4-8 中看到。
图 4-8 。使用逻辑||运算符时生成的输出
清单 4-9 证明了逻辑||操作符将会返回 true,只要其中一个或两个关系操作也为真。当两者都为 false 时||运算符也将返回 false。
注意使用逻辑运算符时有一个常用的优化。一旦操作员满意,执行就会结束。这意味着当第一项为真时,||运算符不会计算第二项,当第一项为假时,& &运算符不会计算第二项。当在右侧语句中调用具有布尔返回值之外的次要效果的函数时,要小心这一点。
食谱 4-4。使用十六进制值
问题
您正在处理包含十六进制值的代码,您需要了解它们是如何工作的。
解决办法
C++ 允许在代码中使用十六进制值,程序员在写出数字的二进制表示时通常使用十六进制值。
它是如何工作的
计算机处理器使用二进制表示在存储器中存储数字,并使用二进制指令来测试和修改这些值。由于其低级本质,C++ 提供了位操作符,可以像处理器一样对变量中的位进行操作。一位信息可以是 1,也可以是 0。我们可以通过使用比特链来构造更高的数。一位可以代表数字 1 或 0。然而,两位可以代表 0、1、2 或 3。这是可以实现的,因为两位可以代表四个独特的信号;00,01,10 和 11。C++ int8_t 数据类型由 8 位组成。表 4-5 中的数据显示了这些不同的位是如何用数字表示的。
表 4-5 。一个 8 位变量中位的数值
存储由表 4-5 的表示的值的 uint8_t 变量将包含数字 137。事实上,一个 8 位变量可以存储 256 个单独的值。你可以计算出一个变量可以存储的值的数量,方法是将数字 2 提高到位数的幂,即 2⁸ 是 256。
注意负数用有符号类型表示,使用与无符号类型相同的位数。在表 4-4 中,一个有符号的值将失去 128 的位置,成为一个符号位。您可以使用数字的二进制补码将正数转换为负数。要做到这一点,你翻转所有的位,并加上 1。对于两位数 1,你会有二进制表示 01。要得到二进制的补码,也就是负数,首先将这些位翻转为 10,然后加上 1,以 11 结尾。在一个 8 位的值中,你将遵循同样的过程。000000001 变成 11111110,加 1 得到 11111111。不管变量中的位数是多少,所有的位都打开时,-1 总是用二进制补码来表示,这是一个需要记住的有用事实。
在处理 16 位、32 位和 64 位数字时,完整地写出位很快就会失控。程序员倾向于用十六进制的格式来写二进制表示。十六进制数由值 0-9 和 A、B、C、D、E 和 F 表示。值 A-F 表示数字 10 到 15。用 4 位来表示 16 个十六进制值,因此我们现在可以用十六进制 0x89 来表示表 4-5 中的位模式,其中 9 代表低 4 位(8+1 是 9),8 代表高 4 位。
清单 4-10 展示了如何在代码中使用十六进制文字,并使用cout
将它们打印到控制台。
清单 4-10 。使用十六进制文字值
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
uint32_t hexValue{ 0x89 };
cout << "Decimal: " << hexValue << endl;
cout << hex << "Hexadecimal: " << hexValue << endl;
cout << showbase << hex << "Hexadecimal (with base): " << hexValue << endl;
return 0;
}
C++ 中的十六进制文字以 0x 开头。这让编译器知道你打算用十六进制而不是十进制来解释这个数字。图 4-9 显示了与清单 4-10 中的 cout 一起使用的不同输出标志的效果。
图 4-9 。打印出十六进制值
默认情况下,cout
流打印整数变量的十进制表示。您必须将标志传递给cout
来改变这种行为。hex
标志通知cout
它应该以十六进制打印数字,但是这不会自动加上 0x 基数。如果您希望您的输出以十六进制数为基础(您通常会这样做,这样其他用户就不会把值读成十进制 89 而不是 137),您可以使用showbase
标志,它会让 cout 把 0x 加到您的十六进制值上。
清单 4-10 以 32 位整数类型存储 0x89 的值,但是表示仍然只有 8 位值。其他 6 位隐式为 0。137 的正确 32 位表示实际上应该是 0x00000089。
这在表示负数(如-1)时更为重要。使用 int32_t 时,0xF 表示 16 或 0x0000000F,其中-1 表示 0xFFFFFFFF。使用十六进制值时,请确保您设置的是真正想要的值。
配方 4-5。用二元运算符进行位旋转
问题
您正在开发一个应用程序,希望将数据打包成尽可能小的格式。
解决办法
您可以使用按位运算符来设置和测试变量上的单个位。
它是如何工作的
C++ 提供了以下按位运算符:
- &(按位与)运算符
- |(按位或)运算符
- ^(异或)运算符
- <
-
(右移)运算符
- (补码)运算符
&(按位与)运算符
按位&运算符返回一个值,该值包含运算符左右两侧设置的所有位。清单 4-11 展示了一个这样的例子。
清单 4-11 。&运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
uint32_t bits{ 0x00011000 };
cout << showbase << hex;
cout << "Result of 0x00011000 & 0x00011000: " << (bits & bits) << endl;
cout << "Result of 0x00011000 & 0x11100111: " << (bits & ~bits) << endl;
return 0;
}
清单 4-11 同时使用了&
和~
操作符。对&
的频繁使用将导致数值 0x00011000 被输出到控制台。&的第二种用法是配合~
使用。~
运算符翻转所有位,因此使用&
的输出将为 0。你可以在图 4-10 中看到这一点。
图 4-10 。清单 4-11 中的输出结果
|(按位或)运算符
按位 or 运算符返回一个值,该值包含运算符左侧和右侧的所有设置位。无论设置了其中一个值还是两个值,都是如此。只有当操作符的左右两侧都没有设置该位置时,才会将 0 放置到位。清单 4-12 显示了使用中的|操作符。
清单 4-12 。
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
uint32_t leftBits{ 0x00011000 };
uint32_t rightBits{ 0x00010100 };
cout << showbase << hex;
cout << "Result of 0x00011000 | 0x00010100: " << (leftBits | rightBits) << endl;
cout << "Result of 0x00011000 & 0x11100111: " << (leftBits | ~leftBits) << endl;
return 0;
}
第一次使用|将得到值 0x00011100,第二次使用将得到 0xFFFFFFFF。在图 4-11 中可以看到这是真的。
图 4-11 。清单 4-12 生成的输出
存储在leftBits
和rightBits
中的值共享一个设置为 1 的位位置。有两个位置,一个位置有一个位设置,另一个没有。所有这三个位都设置在结果值中。第二种用法表明,只要位的位置设置在两个位置中的一个,所有的位都会被设置。当您查看下一个运算符的结果时,这两者之间的区别非常重要。
^(异或)运算符
该操作符的输出与图 4-11 所示的|操作符的输出之间会产生一个比特的差异。这是因为异或运算符仅在左位或右位被置位时将结果位设置为真,而不是在两者都被置位时,也不是在两者都没有被置位时。清单 4-12 中的第一个|操作符导致值 0x00011100 被存储为结果。使用相同的值时,^运算符将导致存储 0x00001100。清单 4-13 显示了这个场景的代码。
清单 4-13 。^算子
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
uint32_t leftBits{ 0x00011000 };
uint32_t rightBits{ 0x00010100 };
cout << showbase << hex;
cout << "Result of 0x00011000 ^ 0x00010100: " << (leftBits ^ rightBits) << endl;
cout << "Result of 0x00011000 ^ 0x11100111: " << (leftBits ^ ~leftBits) << endl;
return 0;
}
产生不同输出的证据可以在图 4-12 中看到。
图 4-12 。清单 4-13 中的^算子生成的输出
<< and >>操作员
左移和右移运算符是方便的工具,允许您将较小的数据集打包到较大的变量中。清单 4-14 显示了将一个值从uint32_t
的低 16 位转移到高 16 位的代码。
清单 4-14 。使用< <运算符
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t maskBits{ 16 };
uint32_t leftShifted{ 0x00001010 << maskBits };
cout << showbase << hex;
cout << "Left shifted: " << leftShifted << endl;
return 0;
}
该代码导致值 0x10100000 存储在变量leftShifted
中。这释放了较低的 16 位,现在可以用来存储另一个 16 位值。清单 4-15 使用了|=
和&
操作符来完成这个任务。
注意每个位运算符都有一个赋值变量,用于类似清单 4-15 中的语句。
清单 4-15 。使用掩码将值打包到变量中
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t maskBits{ 16 };
uint32_t leftShifted{ 0x00001010 << maskBits };
cout << showbase << hex;
cout << "Left shifted: " << leftShifted << endl;
uint32_t lowerMask{ 0x0000FFFF };
leftShifted |= (0x11110110 & lowerMask);
cout << "Packed left shifted: " << leftShifted << endl;
return 0;
}
这段代码现在将两个独立的 16 位值打包到一个 32 位变量中。打包到低 16 位的值通过使用&运算符和屏蔽值(在本例中为 0x0000FFFF)屏蔽掉所有高 16 位。这确保|=运算符保持高 16 位的值不变,因为被“或”运算的值不会设置任何高 16 位。你可以在图 4-13 中看到这一点。
图 4-13 。使用按位运算符将值屏蔽为整数的结果
图 4-13 中的最后两行输出是对变量上下部分的值进行去屏蔽操作的结果。你可以在清单 4-16 中看到这是如何实现的。
清单 4-16 。解除打包数据的屏蔽
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t maskBits{ 16 };
uint32_t leftShifted{ 0x00001010 << maskBits };
cout << showbase << hex;
cout << "Left shifted: " << leftShifted << endl;
uint32_t lowerMask{ 0x0000FFFF };
leftShifted |= (0x11110110 & lowerMask);
cout << "Packed left shifted: " << leftShifted << endl;
uint32_t lowerValue{ (leftShifted & lowerMask) };
cout << "Lower value unmasked: " << lowerValue << endl;
uint32_t upperValue{ (leftShifted >> maskBits) };
cout << "Upper value unmasked: " << upperValue << endl;
return 0;
}
&运算符和>>运算符在清单 4-16 中用于从我们的打包变量中检索两个不同的值。不幸的是,这个代码有一个问题尚未被发现。清单 4-17 提供了这个问题的一个例子。
清单 4-17 。移位和收缩转换
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t maskBits{ 16 };
uint32_t narrowingBits{ 0x00008000 << maskBits };
return 0;
}
清单 4-17 中的代码将无法编译。您将收到一条错误消息,提示将要进行收缩转换,并且您的编译器将阻止您构建可执行文件,直到问题代码得到修复。这里的问题是,值 0x00008000 设置了第 16 个位,一旦它向右移动 16 位,第 32 个位将被设置。在正常情况下,这将导致该值变成负数。在这个阶段,你有两个不同的选择来应对这种情况。
注意那些以前使用过 C++ 的人可能已经注意到,这些示例没有使用
=
操作符来初始化变量,例如uint32_t maskBits = 16;
相反,我使用的是在 C++11 中引入的统一初始化。统一初始化是使用{}
操作符的初始化形式,如下例所示。统一初始化的主要好处是防止我刚刚描述的收缩转换。
清单 4-18 展示了如何使用一个无符号文字来告诉编译器这个值应该是无符号的。
清单 4-18 。使用无符号文字
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t maskBits{ 16 };
uint32_t leftShifted{ 0x00008080u << maskBits };
cout << showbase << hex;
cout << "Left shifted: " << leftShifted << endl;
uint32_t lowerMask{ 0x0000FFFF };
leftShifted |= (0x11110110 & lowerMask);
cout << "Packed left shifted: " << leftShifted << endl;
uint32_t lowerValue{ (leftShifted & lowerMask) };
cout << "Lower value unmasked: " << lowerValue << endl;
uint32_t upperValue{ (leftShifted >> maskBits) };
cout << "Upper value unmasked: " << upperValue << endl;
return 0;
}
将u
添加到数字文字的末尾会导致编译器将该文字作为无符号值进行评估。另一种选择是使用有符号的值。然而,这引入了一个新的考虑。当右移有符号值时,符号位被放入来自右边的新值中。可能会发生以下情况:
- 0x10100000 >> 16 变成 0x00001010
- 0x80800000 >> 16 变成 0xFFFF8080
清单 4-19 和图 4-14 显示了证明负号位传播的代码和输出。
清单 4-19 。负值右移
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t maskBits{ 16 };
int32_t leftShifted{ 0x00008080 << maskBits };
cout << showbase << hex;
cout << "Left shifted: " << leftShifted << endl;
int32_t lowerMask{ 0x0000FFFF };
leftShifted |= (0x11110110 & lowerMask);
cout << "Packed left shifted: " << leftShifted << endl;
int32_t rightShifted{ (leftShifted >> maskBits) };
cout << "Right shifted: " << rightShifted << endl;
cout << "Unmasked right shifted: " << (rightShifted & lowerMask) << endl;
return 0;
}
在清单 4-19 的粗体行中,你可以看到新代码需要两个提取上部屏蔽值。当使用有符号整数时,单独的移位不再适用。图 4-14 显示了证明这一点的输出。
图 4-14 。显示右移后符号位传播的输出
如您所见,我不得不将变量向右移动,屏蔽掉高位,以便从变量的高位检索原始值。在我们移位之后,该值包含十进制值-32,640 (0xFFFF8080),但是我们期望的值实际上是 32,896 (0x00008080)。通过使用&
运算符(0x ffff 8080 | 0x 0000 ffff = 0x00008080)检索 0x 00008080。
五、类
类是将 C++ 与 C 编程语言区分开来的语言特性。向 C++ 中添加类允许它用于使用面向对象编程(OOP)范例设计的程序。OOP 很快成为世界范围内用于构建复杂应用程序的主要软件工程实践。您可以在当今大多数主流语言中找到类支持,包括 Java、C# 和 Objective-C。
配方 5-1。定义类别
问题
您的程序设计需要对象,您需要能够在程序中定义类。
解决办法
C++ 为创建类定义提供了class
关键字和语法。
它是如何工作的
在 C++ 中,class
关键字用于创建类定义。这个关键字后面是类名,然后是类的主体。清单 5-1 显示了一个类定义。
清单 5-1 。类别定义
class Vehicle
{
};
清单 5-1 中的Vehicle
类定义告诉编译器它应该将单词Vehicle
识别为类型。这意味着代码现在可以创建Vehicle
类型的变量。清单 5-2 展示了这一点。
清单 5-2 。创建一个Vehicle
变量
class Vehicle
{
};
int main(int argc, char* argv[])
{
Vehicle myVehicle;
return 0;
}
创建这样一个变量会导致你的程序创建一个对象。在处理类时使用的通用术语中,类定义本身被称为类。该类的变量被称为对象,所以你可以拥有同一个类的多个对象。从一个类创建一个对象的过程被称为实例化一个类。
食谱 5-2。向类中添加数据
问题
您希望能够在您的类中存储数据。
解决办法
C++ 允许类包含变量。每个对象都有自己唯一的变量,并且可以存储自己的值。
它是如何工作的
C++ 有一个成员变量 的概念:一个存在于类定义中的变量。类定义中的每个实例化对象都有自己的变量副本。清单 5-3 显示了一个包含单个成员变量的类。
清单 5-3 。带有成员变量的Vehicle
类
#include <cinttypes>
class Vehicle
{
public:
uint32_t m_NumberOfWheels;
};
Vehicle
类包含一个单独的uint32_t
变量来存储车辆的车轮数量。清单 5-4 展示了如何设置这个值并打印出来。
清单 5-4 。访问成员变量
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
uint32_t m_NumberOfWheels;
};
int main(int argc, char* argv[])
{
Vehicle myCar;
myCar.m_NumberOfWheels = 4;
cout << "Number of wheels: " << myCar.m_NumberOfWheels << endl;
return 0;
}
清单 5-4 展示了你可以使用点(.
)操作符来访问一个对象上的成员变量。该操作符在代码中使用了两次:一次是将m_NumberOfWheels
的值设置为 4,另一次是检索该值并打印出来。清单 5-5 添加了该类的另一个实例,以表明不同的对象可以在其成员中存储不同的值。
清单 5-5 。添加第二个对象
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
uint32_t m_NumberOfWheels;
};
int main(int argc, char* argv[])
{
Vehicle myCar;
myCar.m_NumberOfWheels = 4;
cout << "Number of wheels: " << myCar.m_NumberOfWheels << endl;
Vehicle myMotorcycle;
myMotorcycle.m_NumberOfWheels = 2;
cout << "Number of wheels: " << myMotorcycle.m_NumberOfWheels << endl;
return 0;
}
清单 5-5 添加了第二个对象并命名为myMotorcycle
。这个类的实例将其变量m_NumberOfWheels
设置为 2。您可以在图 5-1 中看到不同的输出值。
图 5-1 。清单 5-5 生成的输出
食谱 5-3。添加方法
问题
您需要能够在一个类上执行可重复的任务。
解决办法
C++ 允许程序员给类添加函数。这些函数被称为成员方法 ,可以访问类成员变量。
它是如何工作的
你可以简单地通过添加一个函数到一个类来添加一个成员方法。您添加的任何函数都可以使用属于该类的成员变量。清单 5-6 展示了两个成员方法。
清单 5-6 。向类中添加成员方法
#include <cinttypes>
class Vehicle
{
public:
uint32_t m_NumberOfWheels;
void SetNumberOfWheels(uint32_t numberOfWheels)
{
m_NumberOfWheels = numberOfWheels;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
清单 5-6 中的所示的Vehicle
类包含两个成员方法:SetNumberOfWheels
获取一个用于设置成员m_NumberOfWheels
的参数,GetNumberOfWheels
获取m_NumberOfWheels
的值。清单 5-7 使用了这些方法。
清单 5-7 。使用来自Vehicle
类的成员方法
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels;
public:
void SetNumberOfWheels(uint32_t numberOfWheels)
{
m_NumberOfWheels = numberOfWheels;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar;
myCar.SetNumberOfWheels(4);
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle;
myMotorcycle.SetNumberOfWheels(2);
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
return 0;
}
成员方法用于改变和检索清单 5-7 中成员变量的值。该代码生成的输出如图 5-2 中的所示。
图 5-2 。清单 5-7 中的代码生成的输出
食谱 5-4。使用访问修饰符
问题
将所有成员变量暴露给调用代码会导致几个问题,包括高耦合性和更高的维护成本。
解决办法
使用 C++ 访问修饰符来利用封装并隐藏调用代码的类实现。
它是如何工作的
C++ 提供了访问修饰符,允许您控制代码是否可以访问内部成员变量和方法。清单 5-8 展示了如何使用private
访问修饰符来限制对变量的访问,以及如何使用public
访问说明符来提供间接访问成员的方法。
清单 5-8 。使用public
和private
访问修饰符
#include <cinttypes>
class Vehicle
{
private:
uint32_t m_NumberOfWheels;
public:
void SetNumberOfWheels(uint32_t numberOfWheels)
{
m_NumberOfWheels = numberOfWheels;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
要使用访问修饰符,请在类中插入关键字,后跟一个冒号。一旦被调用,访问修饰符将应用于所有成员变量和随后的方法,直到指定另一个访问修饰符。在清单 5-8 的中,这意味着m_NumberOfWheels
变量是私有的,而SetNumberOfWheels
和GetNumberOfWheels
成员方法是公共的。
如果你试图在调用代码中直接访问m_NumberOfWheels
,你的编译器会给你一个访问错误。相反,您必须通过成员方法来访问变量。清单 5-9 展示了一个带有私有成员变量的工作示例。
清单 5-9 。使用访问修饰符
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels;
public:
void SetNumberOfWheels(uint32_t numberOfWheels)
{
m_NumberOfWheels = numberOfWheels;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar;
// myCar.m_NumberOfWheels = 4; -Access error
myCar.SetNumberOfWheels(4);
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle;
myMotorcycle.SetNumberOfWheels(2);
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
return 0;
}
通过取消清单 5-9 中粗体行的注释,您可以看到编译器生成的错误。以这种方式封装数据允许您在以后更改实现,而不会影响代码的其余部分。清单 5-10 更新了来自清单 5-9 的代码,使用一种完全不同的方法来计算车辆的车轮数量。
清单 5-10 。改变Vehicle
类的实现
#include <vector>
#include <cinttypes>
#include <iostream>
using namespace std;
class Wheel
{
};
class Vehicle
{
private:
using Wheels = vector<Wheel>;
Wheels m_Wheels;
public:
void SetNumberOfWheels(uint32_t numberOfWheels)
{
m_Wheels.clear();
for (uint32_t i = 0; i < numberOfWheels; ++i)
{
m_Wheels.push_back({});
}
}
uint32_t GetNumberOfWheels()
{
return m_Wheels.size();
}
};
int main(int argc, char* argv[])
{
Vehicle myCar;
myCar.SetNumberOfWheels(4);
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle;
myMotorcycle.SetNumberOfWheels(2);
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
return 0;
}
比较清单 5-9 中的Vehicle
类和清单 5-10 中的Vehicle
类,可以发现SetNumberOfWheels
和GetNumberOfWheels
的实现完全不同。清单 5-10 中的类不将值存储在uint32_t
成员中;相反,它存储了一个Wheel
对象的vector
。对于作为其numberOfWheels
参数提供的数字,SetNumberOfWheels
方法向vector
添加一个新的Wheel
实例。GetNumberOfWheels
方法返回vector
的大小。两个清单中的 main 函数是相同的,执行代码生成的输出也是如此。
食谱 5-5。初始化类成员变量
问题
未初始化的变量会导致未定义的程序行为。
解决办法
C++ 类可以在实例化时初始化其成员变量,并为用户提供的值提供构造函数方法。
它是如何工作的
统一初始化
C++ 中的类可以使用统一初始化在实例化时为类成员提供默认值。统一初始化允许您在初始化从类创建的内置类型或对象时使用通用语法。C++ 使用花括号语法来支持这种形式的初始化。清单 5-11 展示了一个类,它的成员变量以这种方式初始化。
清单 5-11 。初始化类成员变量
#include <cinttypes>
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
uint32 GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
在清单 5-11 的中,类的m_NumberOfWheels
成员使用统一初始化进行初始化。这是通过在名称后使用花括号来实现的。没有向初始值设定项提供值,这导致编译器将值初始化为 0。清单 5-12 展示了这个类在上下文中的使用。
清单 5-12 。使用Vehicle
类
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar;
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle;
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
return 0;
}
图 5-3 显示了该代码生成的输出。
图 5-3 。由清单 5-12 中的代码生成的输出。
图 5-3 显示了每个等级的输出为 0。这是对不初始化数据的代码的改进,如图图 5-4 所示。
图 5-4 。不初始化成员变量的程序产生的输出
使用构造函数
图 5-3 代表比图 5-4 更好的情况,但两者都不理想。您真的希望清单 5-12 中的myCar
和myMotorcycle
对象打印不同的值。清单 5-13 添加了一个构造函数,这样你就可以在实例化类时指定轮子的数量。
清单 5-13 。向类中添加构造函数
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
Vehicle(uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar{ 4 };
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle{ 2 };
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
return 0;
}
清单 5-13 增加了在实例化时初始化Vehicle
上轮子数量的能力。它通过向Vehicle
类添加一个构造函数来实现这一点,该构造函数将车轮数量作为参数。使用构造函数可以让您依赖于在对象创建时发生的函数调用。该函数用于确保类中包含的所有成员变量都已正确初始化。未初始化的数据是导致意外程序行为(如崩溃)的一个非常常见的原因。
myCar
和myMotorcycle
对象使用不同的轮子数量值进行实例化。不幸的是,向该类添加构造函数意味着您不能再构造该类的默认版本;你必须始终在清单 5-13 中提供一个车轮数量值。清单 5-14 通过在类中添加一个显式的默认操作符来克服这个限制。
清单 5-14 。默认构造函数
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar{ 4 };
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle{ 2 };
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
Vehicle noWheels;
cout << "Number of wheels: " << noWheels.GetNumberOfWheels() << endl;
return 0;
}
清单 5-14 中的Vehicle
类包含一个显式的default
构造函数。default
关键字和equals
操作符一起使用,通知编译器你想给这个类添加一个default
构造函数。由于m_NumberOfWheels
变量的统一初始化,您可以创建一个在m_NumberOfWheels
变量中包含 0 的类noWheels
的实例。图 5-5 显示了该代码生成的输出。
图 5-5 。清单 5-14 生成的输出,显示了noWheels
类中的 0
配方 5-6。清理班级
问题
当一个对象被销毁时,一些类需要清理它们的成员。
解决办法
C++ 允许将析构函数添加到类中,当类被销毁时,允许代码被执行。
它是如何工作的
您可以使用~
语法向 C++ 中的类添加一个特殊的析构函数方法。清单 5-15 展示了如何实现这一点。
清单 5-15 。向类中添加析构函数
#include <cinttypes>
#include <string>
using namespace std;
class Vehicle
{
private:
string m_Name;
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(string name, uint32_t numberOfWheels)
: m_Name{ name }
, m_NumberOfWheels{ numberOfWheels }
{
}
~Vehicle()
{
cout << m_Name << " is being destroyed!" << endl;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
清单 5-15 中的类包含一个析构函数。这个析构函数只是打印出被销毁对象的名字。构造函数可以用对象名初始化,Vehicle
的默认构造函数自动调用string
类的默认构造函数。清单 5-16 展示了如何在实践中使用这个类。
清单 5-16 。使用带有析构函数的类
#include <cinttypes>
#include <iostream>
#include <string>
using namespace std;
class Vehicle
{
private:
string m_Name;
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(string name, uint32_t numberOfWheels)
: m_Name{ name }
, m_NumberOfWheels{ numberOfWheels }
{
}
~Vehicle()
{
cout << m_Name << " is being destroyed!" << endl;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar{ "myCar", 4 };
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle{ "myMotorcycle", 2 };
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
Vehicle noWheels;
cout << "Number of wheels: " << noWheels.GetNumberOfWheels() << endl;
return 0;
}
从清单 5-16 中的 main 函数可以看出,你不需要添加任何特殊的代码来调用一个类析构函数。当对象超出范围时,析构函数被自动调用。在这种情况下,对Vehicle
对象的析构函数的调用发生在return
之后。图 5-6 显示了这个程序的输出,证明析构函数代码被执行。
图 5-6 。清单 5-16 生成的输出,显示析构函数已经被执行
注意这些析构函数的调用顺序很重要。对象被销毁的顺序与它们被创建的顺序相反。如果您的资源依赖于以正确的顺序创建和销毁,这一点很重要。
如果您没有定义自己的析构函数,编译器会隐式创建一个默认的析构函数。你也可以使用清单 5-17 中的代码显式定义一个析构函数。
清单 5-17 。显式定义析构函数
#include <cinttypes>
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
}
~Vehicle() = default;
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
始终明确默认的构造函数和析构函数被认为是一种好的做法。这样做可以消除代码中的任何歧义,并让其他程序员知道您对默认行为感到满意。这段代码的省略可能会导致其他人认为您忽略了它的包含。
食谱 5-7。复制类
问题
您希望确保以正确的方式将数据从一个对象复制到另一个对象。
解决办法
C++ 提供了复制构造函数和赋值操作符,您可以使用它们将代码添加到您的类中,在复制发生时执行这些代码。
它是如何工作的
您可以在许多情况下复制 C++ 中的对象。当您将一个对象传递给同类型的另一个对象的构造函数时,它就被复制了。当您将一个对象分配给另一个对象时,也会复制一个对象。通过值将对象传递给函数或方法也会导致复制操作的发生。
隐式和默认复制构造函数和赋值运算符
C++ 类通过复制构造函数和赋值操作符支持这些操作。清单 5-18 显示了在 main 方法中调用的这些方法的默认版本。
清单 5-18 。使用复制构造函数和赋值运算符
#include <cinttypes>
#include <iostream>
#include <string>
using namespace std;
class Vehicle
{
private:
string m_Name;
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(string name, uint32_t numberOfWheels)
: m_Name{ name }
, m_NumberOfWheels{ numberOfWheels }
{
}
~Vehicle()
{
cout << m_Name << " at " << this << " is being destroyed!" << endl;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar{ "myCar", 4 };
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle{ "myMotorcycle", 2 };
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
Vehicle myCopiedCar{ myCar };
cout << "Number of wheels: " << myCopiedCar.GetNumberOfWheels() << endl;
Vehicle mySecondCopy;
mySecondCopy = myCopiedCar;
cout << "Number of wheels: " << mySecondCopy.GetNumberOfWheels() << endl;
return 0;
}
使用复制构造函数来构造myCopiedCar
变量。这是通过将另一个相同类型的对象传递到myCopiedCar
的大括号初始化器中来实现的。mySecondCopy
变量是使用默认的构造函数构造的。因此,该对象用一个空名称和 0 作为轮子的数量进行初始化。然后代码使用myCopiedCar
将值分配给mySecondCopy
。你可以在图 5-7 中看到这些操作的结果。
图 5-7 。清单 5-18 生成的输出
正如所料,您有三个名为myCar
的对象,每个对象都有四个轮子。当析构函数输出每个对象在内存中的地址时,你可以看到不同的对象。
显式复制构造函数和赋值运算符
清单 5-18 中的代码利用了隐式复制构造函数和赋值操作符。当 C++ 编译器遇到使用这些函数的代码时,它会自动将这些函数添加到您的类中。清单 5-19 展示了如何显式地创建这些函数。
清单 5-19 。显式创建复制构造函数和赋值运算符
#include <cinttypes>
#include <iostream>
#include <string>
using namespace std;
class Vehicle
{
private:
string m_Name;
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(string name, uint32_t numberOfWheels)
: m_Name{ name }
, m_NumberOfWheels{ numberOfWheels }
{
}
~Vehicle()
{
cout << m_Name << " at " << this << " is being destroyed!" << endl;
}
Vehicle(const Vehicle& **other) = default;**
**Vehicle**`&` **operator=(const Vehicle**`&` **other) = default;**
`uint32_t GetNumberOfWheels()`
`{`
`return m_NumberOfWheels;`
`}`
`};`
复制构造函数的签名类似于普通构造函数的签名。这是一个没有返回类型的方法;但是,复制构造函数将对同一类型对象的常量引用作为参数。当语句的右边是同类型的另一个对象时,赋值操作符使用操作符重载来重载该类的
=算术操作符,如在
someVehicle = someOtherVehicle中。
default`关键字再次变得有用,它允许你与其他程序员交流,你对默认操作感到满意。
不允许复制和转让
有时你会创建一些你绝对不希望使用复制构造函数和赋值操作符的类。C++ 为这些情况提供了delete
关键字。清单 5-20 展示了这是如何实现的。
清单 5-20 。不允许复制和转让
#include <cinttypes>
#include <iostream>
#include <string>
using namespace std;
class Vehicle
{
private:
string m_Name;
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(string name, uint32_t numberOfWheels)
: m_Name{ name }
, m_NumberOfWheels{ numberOfWheels }
{
}
~Vehicle()
{
cout << m_Name << " at " << this << " is being destroyed!" << endl;
}
Vehicle(const Vehicle& other) = delete;
Vehicle& operator=(const Vehicle& other) = delete;
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myCar{ "myCar", 4 };
cout << "Number of wheels: " << myCar.GetNumberOfWheels() << endl;
Vehicle myMotorcycle{ "myMotorcycle", 2 };
cout << "Number of wheels: " << myMotorcycle.GetNumberOfWheels() << endl;
Vehicle myCopiedCar{ myCar };
cout << "Number of wheels: " << myCopiedCar.GetNumberOfWheels() << endl;
Vehicle mySecondCopy;
mySecondCopy = myCopiedCar;
cout << "Number of wheels: " << mySecondCopy.GetNumberOfWheels() << endl;
return 0;
}
delete
关键字用来代替default
来通知编译器你不希望复制和赋值操作对一个类可用。main 函数中的代码将不再编译和运行。
自定义复制构造函数和赋值运算符
除了使用这些操作的默认版本,还可以提供您自己的版本。这是通过对类定义中的方法使用相同的签名,但提供一个方法体来代替默认赋值来实现的。
在现代 C++ 中,你重载这些操作符的地方往往是有限的;但是重要的是要知道你绝对想这么做的地方。默认的复制和赋值操作执行浅复制 。它们在对象的每个成员上调用赋值操作符,并从传入的类中赋值。有些情况下,您有一个手动管理资源(如内存)的类,而一个浅表副本在两个类中都有一个指向内存中相同地址的指针。如果内存是在类的析构函数中释放的,那么就会出现一个对象指向另一个对象释放的内存的情况。在这种情况下,您的程序很可能会崩溃或表现出其他奇怪的行为。清单 5-21 显示了一个可能发生这种情况的例子。
清单 5-21 。浅复制 C 样式的字符串成员
#include <cinttypes>
#include <cstring>
#include <iostream>
using namespace std;
class Vehicle
{
private:
char* m_Name{};
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(const char* name, uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
const uint32_t length = strlen(name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, name);
}
~Vehicle()
{
delete m_Name;
m_Name = nullptr;
}
Vehicle(const Vehicle& other) = default;
Vehicle& operator=(const Vehicle& other) = default;
char* GetName()
{
return m_Name;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myAssignedCar;
{
Vehicle myCar{ "myCar", 4 };
cout << "Vehicle name: " << myCar.GetName() << endl;
myAssignedCar = myCar;
cout << "Vehicle name: " << myAssignedCar.GetName() << endl;
}
cout << "Vehicle name: " << myAssignedCar.GetName() << endl;
return 0;
}
注意清单 5-21 中的代码是有目的地构建的,以创建一个使用 STL 字符串类可以更好解决的情况。这段代码只是一个简单易懂的例子,说明事情是如何出错的。
清单 5-21 中的 main 函数创建了Vehicle
类的两个实例。第二个是在块中创建的。当块结束并且对象超出范围时,这个块导致myCar
对象被析构。这是一个问题,因为代码块的最后一行调用了赋值操作符,并对类成员进行了浅层复制。在这发生之后,myCar
和myAssignedCar
对象在它们的m_Name
变量中指向相同的内存地址。在代码试图打印出myAssignedCar
的名字之前,这个内存在myCar
的析构函数中被释放。你可以在图 5-8 中看到这个错误的结果。
图 5-8 。显示在销毁对象之前浅复制对象的错误的输出
图 5-8 证明了浅拷贝导致代码处于危险境地。一旦myCar
变量被销毁,由myAssignedCar
中的m_Name
变量指向的内存就不再有效。清单 5-22 通过提供一个复制构造函数和一个赋值操作符来实现类的深度复制,解决了这个问题。
清单 5-22 。执行深度复制
#include <cinttypes>
#include <cstring>
#include <iostream>
using namespace std;
class Vehicle
{
private:
char* m_Name{};
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(const char* name, uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
const uint32_t length = strlen(name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, name);
}
~Vehicle()
{
delete m_Name;
m_Name = nullptr;
}
Vehicle(const Vehicle& **other)**
**{**
**const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator**
**m_Name = new char[length]{};**
**strcpy(m_Name, other.m_Name);**
**m_NumberOfWheels = other.m_NumberOfWheels;**
**}**
**Vehicle&** `**operator=(const Vehicle&** `**other)**
**{**
**if (m_Name != nullptr)**
**{**
**delete m_Name;**
**}**
**const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator**
**m_Name = new char[length]{};**
**strcpy(m_Name, other.m_Name);**
**m_NumberOfWheels = other.m_NumberOfWheels;**
**return *this;**
**}**
`char* GetName()`
`{`
`return m_Name;`
`}`
`uint32_t GetNumberOfWheels()`
`{`
`return m_NumberOfWheels;`
`}`
`};`
`int main(int argc, char* argv[])`
`{`
`Vehicle myAssignedCar;`
`{`
`Vehicle myCar{ "myCar", 4 };`
`cout << "Vehicle name: " << myCar.GetName() << endl;`
`myAssignedCar = myCar;`
`cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`
`}`
`cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`
`return 0;`
`}```
```cpp
``这一次,代码提供了在发生复制或赋值时要执行的方法。当通过复制旧对象来创建新对象时,会调用复制构造函数,因此您永远不需要担心删除旧数据。另一方面,赋值操作符不能保证现有的类不存在。当赋值操作符负责任地删除为现有的`m_Name`变量分配的内存时,您可以看到这一点的含义。这些深度复制的结果可以在图 5-9 中看到。
![9781484201589_Fig05-09.jpg](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/cpp-recipe-prob-sol-approach/img/9781484201589_Fig05-09.jpg)
图 5-9 。使用深层副本的结果
由于使用了深层拷贝,现在输出是正确的。这给了`myAssignedCar`变量它自己的`name`字符串的副本,而不是简单地给它的指针分配与`myCar`类相同的地址。在这种情况下,解决问题的正确方法是使用 STL 字符串来代替 C 风格的字符串,但是如果您将来不得不编写可能指向相同的动态分配内存或堆栈内存的类,那么这个示例将是有效的。
食谱 5-8。用移动语义优化代码
问题
您的代码运行缓慢,您认为问题是由复制临时对象引起的。
解决办法
C++ 以移动构造函数和移动赋值操作符的形式提供了对移动语义的支持。
它是如何工作的
清单 5-23 中显示的代码执行一个对象的深度复制,以避免不同的对象指向一个无效的内存地址。
***清单 5-23*** 。使用深度拷贝避免无效指针
include
include
include
using namespace std;
class Vehicle
{
private:
char* m_Name{};
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(const char* name, uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
const uint32_t length = strlen(name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, name);
}
~Vehicle()
{
delete m_Name;
m_Name = nullptr;
}
Vehicle(const Vehicle& other)
{
const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, other.m_Name);
m_NumberOfWheels = other.m_NumberOfWheels;
}
Vehicle& **operator=(const Vehicle&**
other)
{
if (m_Name != nullptr)
{
delete m_Name;
}
const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, other.m_Name);
m_NumberOfWheels = other.m_NumberOfWheels;
*return this;
}
char* GetName()
{
return m_Name;
}
uint32_t GetNumberOfWheels()
{
return m_NumberOfWheels;
}
};
int main(int argc, char* argv[])
{
Vehicle myAssignedCar;
{
Vehicle myCar{ "myCar", 4 };
cout << "Vehicle name: " << myCar.GetName() << endl;
myAssignedCar = myCar;
cout << "Vehicle name: " << myAssignedCar.GetName() << endl;
}
cout << "Vehicle name: " << myAssignedCar.GetName() << endl;
return 0;
`}```cpp
``当您知道两个对象可能存在相当长的时间,但其中一个可能在另一个之前被销毁,这可能会导致崩溃时,这是正确的解决方案。然而,有时你知道你复制的对象将要被销毁。C++ 允许你使用移动语义来优化这种情况。清单 5-24 给类添加了一个移动构造函数和一个移动赋值操作符,并使用`move`函数来调用它们。
***清单 5-24*** 。移动构造函数和移动赋值运算符
```cpp
#include <cinttypes>
#include <cstring>
#include <iostream>
using namespace std;
class Vehicle
{
private:
char* m_Name{};
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
Vehicle(const char* name, uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
const uint32_t length = strlen(name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, name);
}
~Vehicle()
{
if (m_Name != nullptr)
{
delete m_Name;
m_Name = nullptr;
}
}
Vehicle(const Vehicle& other)
{
const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, other.m_Name);
m_NumberOfWheels = other.m_NumberOfWheels;
}
Vehicle& operator=(const Vehicle& other)
{
if (m_Name != nullptr)
{
delete m_Name;
}
const uint32_t length = strlen(other.m_Name) + 1; // Add space for null terminator
m_Name = new char[length]{};
strcpy(m_Name, other.m_Name);
m_NumberOfWheels = other.m_NumberOfWheels;
return *this;
}
Vehicle(Vehicle&& **other)**
**{**
**m_Name = other.m_Name;**
**other.m_Name = nullptr;**
**m_NumberOfWheels = other.m_NumberOfWheels;**
**}**
**Vehicle&** `**operator=(Vehicle&&** `**other)**
**{**
**if (m_Name != nullptr)**
**{**
**delete m_Name;**
**}**
**m_Name = other.m_Name;**
**other.m_Name = nullptr;**
**m_NumberOfWheels = other.m_NumberOfWheels;**
**return *this;**
**}**
`char* GetName()`
`{`
`return m_Name;`
`}`
`uint32_t GetNumberOfWheels()`
`{`
`return m_NumberOfWheels;`
`}`
`};`
`int main(int argc, char* argv[])`
`{`
`Vehicle myAssignedCar;`
`{`
`Vehicle myCar{ "myCar", 4 };`
`cout << "Vehicle name: " << myCar.GetName() << endl;`
**myAssignedCar = move(myCar);**
`//cout << "Vehicle name: " << myCar.GetName() << endl;`
`cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`
`}`
`cout << "Vehicle name: " << myAssignedCar.GetName() << endl;`
`return 0;`
`}```
```cpp
``Move 语义通过提供将`rvalue`引用作为参数的类方法来工作。这些`rvalue`引用通过在参数类型上使用双&运算符来表示。您可以使用`move`功能调用移动操作;您可以在`main`函数中看到这一点。这里可以使用`move`函数,因为你知道`myCar`即将被销毁。调用移动赋值操作符,指针地址被浅拷贝到`myAssignedCar`。移动赋值操作符释放对象可能已经使用了`m_Name`的内存。重要的是,在将`other.m_Name`设置为`nullptr`之前,它会从`other`复制地址。将`other`对象的指针设置为`nullptr`可以防止该对象删除其析构函数中的内存。在这种情况下,代码能够将`m_Name`的值从`other`移动到`this`,而不必分配更多的内存并将值从一个深度复制到另一个。最终的结果是你不能再使用由`myCar`存储的`m_Name`的值——清单 5-24 的`main`函数中被注释掉的行将导致崩溃。```````
# 六、继承
C++ 允许你以多种方式构建复杂的软件应用程序。其中最常见的是面向对象编程(OOP)范式。C++ 中的类用于为包含数据的对象以及可以对该数据执行的操作提供蓝图。
继承通过让您构造复杂的类层次结构而更进一步。C++ 语言提供了各种不同的特性,您可以使用这些特性以逻辑方式组织代码。
食谱 6-1。从类继承
问题
您正在编写一个程序,它在对象之间有一种自然的 is-a 关系,并且希望减少代码重复。
解决办法
从父类继承类允许您将代码添加到父类中,并在多个派生类型之间共享它。
它是如何工作的
在 C++ 中,你可以从一个类继承另一个类。继承类获得基类的所有属性。清单 6-1 显示了一个从共享父类继承的两个类的例子。
***清单 6-1*** 。类继承
```cpp
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
Vehicle(uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
}
uint32_t GetNumberOfWheels() const
{
return m_NumberOfWheels;
}
};
class Car : public Vehicle
{
public:
Car()
: Vehicle(4)
{
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle()
: Vehicle(2)
{
}
};
int main(int argc, char* argv[])
{
Car myCar{};
cout << "A car has " << myCar.GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
cout << "A motorcycle has " << myMotorcycle.GetNumberOfWheels() << " wheels." << endl;
return 0;
}
Vehicle
类包含一个成员变量来存储车辆的车轮数量。默认情况下,该值初始化为 0,或者在构造函数中设置。Vehicle
后面是另一个名为Car
的类。Car
类只包含一个用于调用Vehicle
构造函数的构造函数。Car
构造函数将数字 4 传递给Vehicle
构造函数,因此将m_NumberOfWheels
设置为 4。
Motorcycle
类也只包含一个构造函数,但是它将 2 传递给了Vehicle
构造函数。因为Car
和Motorcycle
都继承自Vehicle
类,所以它们都继承了它的属性。它们都包含一个保存车轮数量的变量,并且都有一个检索车轮数量的方法。您可以在main
函数中看到这一点,其中GetNumberOfWheels
在myCar
对象和myMotorcycle
对象上都被调用。图 6-1 显示了这段代码生成的输出。
图 6-1 。由清单 6-1 中的代码生成的输出
Car
类和Motorcycle
类都继承了Vehicle
的属性,并且都在它们的构造函数中设置了适当的轮数。
食谱 6-2。控制对派生类中成员变量和方法的访问
问题
您的派生类需要能够访问其父类中的字段。
解决办法
C++ 访问修饰符对在派生类中访问变量的方式有影响。使用正确的访问修饰符是正确构造类层次结构的关键。
它是如何工作的
公共访问说明符
public
访问说明符 授予对类中变量或方法的公共访问权。这同样适用于成员变量和方法。你可以在清单 6-2 中清楚地看到这一点。
清单 6-2 。访问说明符
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
uint32_t m_NumberOfWheels{};
Vehicle() = default;
};
class Car : public Vehicle
{
public:
Car()
{
m_NumberOfWheels = 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle()
{
m_NumberOfWheels = 2;
}
};
int main(int argc, char* argv[])
{
Car myCar{};
cout << "A car has " << myCar.m_NumberOfWheels << " wheels." << endl;
myCar.m_NumberOfWheels = 3;
cout << "A car has " << myCar.m_NumberOfWheels << " wheels." << endl;
Motorcycle myMotorcycle;
cout << "A motorcycle has " << myMotorcycle.m_NumberOfWheels << " wheels." << endl;
myMotorcycle.m_NumberOfWheels = 3;
cout << "A motorcycle has " << myMotorcycle.m_NumberOfWheels << " wheels." << endl;
return 0;
}
任何具有public
访问权限的变量都可以被派生类访问。Car
构造器和Motorcycle
构造器都利用了这一点,并适当地设置了它们拥有的轮数。缺点是其他代码也可以访问公共成员变量。你可以在main
函数中看到这一点,其中m_NumberOfWheels
被读取并分配给myCar
对象和myMotorcycle
对象。图 6-2 显示了该代码生成的输出。
图 6-2 。清单 6-2 生成的输出
私有访问说明符
您可以将变量设为私有并为其提供公共访问器,而不是将其设为公共。清单 6-3 显示了私有成员变量的使用。
清单 6-3 。private
访问说明符
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
private:
uint32_t m_NumberOfWheels{};
public:
Vehicle(uint32_t numberOfWheels)
: m_NumberOfWheels{ numberOfWheels }
{
}
uint32_t GetNumberOfWheels() const
{
return m_NumberOfWheels;
}
};
class Car : public Vehicle
{
public:
Car()
: Vehicle(4)
{
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle()
: Vehicle(2)
{
}
};
int main(int argc, char* argv[])
{
Car myCar{};
cout << "A car has " << myCar.GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
cout << "A motorcycle has " << myMotorcycle.GetNumberOfWheels() << " wheels." << endl;
return 0;
}
清单 6-3 显示了private
访问说明符与m_NumberOfWheels
变量的使用。Car
和Motorcycle
类不再能直接访问m_NumberOfWheels
变量;因此,Vehicle
类提供了一种通过其构造函数初始化变量的方法。这使得类更难处理,但是增加了不允许任何外部代码直接访问成员变量的好处。您可以在main
函数中看到这一点,其中的代码必须通过GetNumberOfWheels
访问器方法获得车轮的数量。
受保护的访问说明符
protected
访问说明符允许混合使用public
和private
访问说明符。对于从当前类派生的类,它就像一个public
说明符,对于外部代码,它就像一个private
说明符。清单 6-4 展示了这种行为。
清单 6-4 。protected
访问说明符
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
protected:
uint32_t m_NumberOfWheels{};
public:
Vehicle() = default;
uint32_t GetNumberOfWheels() const
{
return m_NumberOfWheels;
}
};
class Car : public Vehicle
{
public:
Car()
{
m_NumberOfWheels = 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle()
{
m_NumberOfWheels = 2;
}
};
int main(int argc, char* argv[])
{
Car myCar{};
cout << "A car has " << myCar.GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
cout << "A motorcycle has " << myMotorcycle.GetNumberOfWheels() << " wheels." << endl;
return 0;
}
清单 6-4 显示了Car
和Motorcycle
都可以直接从它们的父类Vehicle
中访问m_NumberOfWheels
变量。这两个类都在它们的构造函数中设置了m_NumberOfWheels
变量。main
函数中的调用代码不能访问这个变量,因此必须调用GetNumberOfWheels
方法才能打印这个值。
食谱 6-3。隐藏派生类中的方法
问题
您有一个派生类,它需要一个不同于父类提供的行为的方法中的行为。
解决办法
C++ 允许您通过在派生类中定义一个具有相同签名的方法来隐藏父类中的方法。
它是如何工作的
通过在基类中定义具有完全相同签名的方法,可以隐藏父类中的方法。此示例显示派生类如何使用显式方法隐藏来提供不同于父类的功能。当你使用继承时,这是一个需要理解的关键概念,因为它是用来区分类类型层次的主要方法。
清单 6-5 包含一个Vehicle
类、一个Car
类和一个Motorcycle
类。Vehicle
类定义了一个名为GetNumberOfWheels
的方法,该方法返回 0。在Car
类和Motorcycle
类中定义了相同的方法;这些版本的方法分别返回 4 和 2。
清单 6-5 。隐藏方法
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
Vehicle() = default;
uint32_t GetNumberOfWheels() const
{
return 0;
}
};
class Car : public Vehicle
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const
{
return 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle() = default;
uint32_t GetNumberOfWheels() const
{
return 2;
}
};
int main(int argc, char* argv[])
{
Vehicle myVehicle{};
cout << "A vehicle has " << myVehicle.GetNumberOfWheels() << " wheels." << endl;
Car myCar{};
cout << "A car has " << myCar.GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
cout << "A motorcycle has " << myMotorcycle.GetNumberOfWheels() << " wheels." << endl;
return 0;
}
清单 6-5 中的main
函数调用GetNumberOfWheels
的三个不同版本,并为每个版本返回适当的值。您可以在图 6-3 中看到这段代码生成的输出。
图 6-3 。执行清单 6-5 中的代码生成的输出
通过对象或指向这些类类型的指针直接访问这些方法会产生正确的输出。
注意当你使用多态时,方法隐藏不能正常工作。通过指向基类的指针访问派生类会导致基类上的方法被调用。这很少是你想要的行为。使用多态性时的正确解决方案见配方 8-5。
食谱 6-4。使用多态基类
问题
您希望编写泛型代码,它使用指向基类的指针,并且仍然调用派生类中的正确方法。
解决办法
virtual
关键字 允许你创建可以被派生类覆盖的方法。
它是如何工作的
关键字virtual
告诉 C++ 编译器你希望一个类包含一个虚拟方法表(v-table)。v-table 包含对方法的查找,允许为给定类型调用正确的方法,即使对象是通过指向其父类之一的指针来访问的。清单 6-6 显示了一个类层次结构,它使用virtual
关键字来指定一个方法应该包含在类的 v 表中。
清单 6-6 。创建虚拟方法
#include <cinttypes>
class Vehicle
{
public:
Vehicle() = default;
virtual uint32_t GetNumberOfWheels() const
{
return 2;
}
};
class Car : public Vehicle
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const override
{
return 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle() = default;
};
清单 6-6 中的Car
和Motorcycle
类是从Vehicle
类派生而来的。Vehicle
类中的GetNumberOfWheels
方法被列为虚拟方法。这使得通过指针对该方法的任何调用都将通过 v 表来调用。清单 6-7 显示了一个完整的例子,其中的main
函数通过一个Vehicle
指针访问对象。
清单 6-7 。通过基指针访问虚方法
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
Vehicle() = default;
virtual uint32_t GetNumberOfWheels() const
{
return 2;
}
};
class Car : public Vehicle
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const override
{
return 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle() = default;
};
int main(int argc, char* argv[])
{
Vehicle* pVehicle{};
Vehicle myVehicle{};
pVehicle = &myVehicle;
cout << "A vehicle has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
Car myCar{};
pVehicle = &myCar;
cout << "A car has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
pVehicle = &myMotorcycle;
cout << "A motorcycle has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
return 0;
}
main
函数在第一行定义了一个指向Vehicle
对象的指针。然后这个指针被用在每个cout
语句中来访问当前对象的GetNumberOfWheels
方法。Vehicle
和Motorcycle
对象在它们的 v 表中有Vehicle::GetNumberOfWheels
方法的地址;因此,两者都为它们的轮数返回 2。
Car
类覆盖了GetNumberOfWheels
方法。这使得Car
用Car::GetNumberOfWheels
的地址替换查找表中Vehicle::GetNumberOfWheels
的地址。因此,当同一个Vehicle
指针被分配了myCar
的地址并随后调用GetNumberOfWheels
时,它调用的是Car
类中定义的方法,而不是Vehicle
类中定义的方法。图 6-4 显示了清单 6-7 中的代码生成的输出,你可以看到情况就是这样。
图 6-4 。执行清单 6-7 中的代码生成的输出
override
关键字用在Car
类中GetNumberOfWheels
方法签名的末尾。该关键字是对编译器的一个提示,即您希望此方法重写父类中的虚方法。如果您输入的签名不正确,或者您正在重写的方法的签名后来被更改,编译器将会引发错误。这个特性非常有用,我推荐你使用它(虽然override
关键字本身是可选的)。
食谱 6-5。防止方法重写
问题
您有一个不想被派生类重写的方法。
解决办法
你可以使用关键字final
来防止类覆盖方法 。
它是如何工作的
关键字通知编译器你不希望一个虚方法被派生类覆盖。清单 6-8 展示了一个使用final
关键字的例子。
清单 6-8 。使用final
关键字
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
Vehicle() = default;
virtual uint32_t GetNumberOfWheels() const final
{
return 2;
}
};
class Car : public Vehicle
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const override
{
return 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle() = default;
};
int main(int argc, char* argv[])
{
Vehicle* pVehicle{};
Vehicle myVehicle{};
pVehicle = &myVehicle;
cout << "A vehicle has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
Car myCar{};
pVehicle = &myCar;
cout << "A car has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
pVehicle = &myMotorcycle;
cout << "A motorcycle has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
return 0;
}
Vehicle
类中的GetNumberOfWheels
方法使用final
关键字来防止派生类试图重写它。这导致清单 6-8 中的代码无法编译,因为Car
类试图覆盖GetNumberOfWheels
。您可以注释掉此方法来编译代码。
关键字final
也可以在一个更长的链中停止方法的进一步重写。清单 6-9 展示了这是如何实现的。
清单 6-9 。防止继承层次结构中的重写
#include <cinttypes>
class Vehicle
{
public:
Vehicle() = default;
virtual uint32_t GetNumberOfWheels() const
{
return 2;
}
};
class Car : public Vehicle
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const final
{
return 4;
}
};
class Ferrari : public Car
{
public:
Ferrari() = default;
uint32_t GetNumberOfWheels() const override
{
return 5;
}
};
Vehicle
定义了一个名为GetNumberOfWheels
的虚拟方法,该方法返回值 2。Car
覆盖这个方法返回 4(这个例子忽略了不是所有的汽车都有四个轮子的事实)并声明这个方法是最终的。不允许从Car
派生的其他类覆盖相同的方法。如果需求只需要支持四轮汽车,这对应用程序来说是有意义的。当编译器到达任何从Car
派生的类或者从任何其他层次结构中有Car
的类派生的类并且试图覆盖GetNumberOfWheels
方法时,它将抛出一个错误。
食谱 6-6。创建界面
问题
您有一个基类方法,它不应该定义任何行为,而应该简单地被派生类重写。
解决办法
您可以在 C++ 中创建不定义方法体的纯虚拟方法。
它是如何工作的
你可以在 C++ 中通过在方法签名的末尾添加= 0
来定义纯虚方法。清单 6-10 显示了一个例子。
清单 6-10 。创建纯虚拟方法
#include <cinttypes>
#include <iostream>
using namespace std;
class Vehicle
{
public:
Vehicle() = default;
virtual uint32_t GetNumberOfWheels() const = 0;
};
class Car : public Vehicle
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const override
{
return 4;
}
};
class Motorcycle : public Vehicle
{
public:
Motorcycle() = default;
uint32_t GetNumberOfWheels() const override
{
return 2;
}
};
int main(int argc, char* argv[])
{
Vehicle* pVehicle{};
Car myCar{};
pVehicle = &myCar;
cout << "A car has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
Motorcycle myMotorcycle;
pVehicle = &myMotorcycle;
cout << "A motorcycle has " << pVehicle->GetNumberOfWheels() << " wheels." << endl;
return 0;
}
Vehicle
类将GetNumberOfWheels
定义为一个纯虚拟方法。这就确保了Vehicle
类型的对象永远不会被创建。编译器不允许这样做,因为它没有一个方法来调用GetNumberOfWheels. Car
和Motorcycle
都覆盖了这个方法并且可以被实例化。您可以在main
功能中看到这种情况。图 6-5 显示这些方法返回了Car
和Motorcycle
的正确值。
图 6-5 。执行清单 6-10 中的代码生成的输出
包含纯虚拟方法的类被称为接口。如果一个类从一个接口继承,并且您希望能够实例化该类,您必须重写父类中的任何纯虚方法。可以从一个接口派生而不覆盖这些方法,但是这个派生类只能作为进一步派生类的接口。
食谱 6-7。多重继承
问题
您有一个希望从多个父类派生的类。
解决办法
C++ 支持多重继承 。
它是如何工作的
在 C++ 中,可以使用逗号分隔的父类列表从多个父类中派生出一个类。清单 6-11 展示了这是如何实现的。
清单 6-11 。多重继承
#include <cinttypes>
#include <iostream>
using namespace std;
class Printable
{
public:
virtual void Print() = 0;
};
class Vehicle
{
public:
Vehicle() = default;
virtual uint32_t GetNumberOfWheels() const = 0;
};
class Car
: public Vehicle
, public Printable
{
public:
Car() = default;
uint32_t GetNumberOfWheels() const override
{
return 4;
}
void Print() override
{
cout << "A car has " << GetNumberOfWheels() << " wheels." << endl;
}
};
class Motorcycle
: public Vehicle
, public Printable
{
public:
Motorcycle() = default;
uint32_t GetNumberOfWheels() const override
{
return 2;
}
void Print() override
{
cout << "A motorcycle has " << GetNumberOfWheels() << " wheels." << endl;
}
};
int main(int argc, char* argv[])
{
Printable* pPrintable{};
Car myCar{};
pPrintable = &myCar;
pPrintable->Print();
Motorcycle myMotorcycle;
pPrintable = &myMotorcycle;
pPrintable->Print();
return 0;
}
Car
和Motorcycle
类都来自多个父类。这些类现在都是Vehicle
和Printable
的。你可以在被覆盖的Print
方法中看到两个父类之间的相互作用。这些方法都调用了Car
和Motorcycle
中被覆盖的GetNumberOfWheels
方法。main
函数通过指向Printable
对象的指针访问被覆盖的Print
方法,使用多态调用正确的Print
方法以及Print
中正确的GetNumberOfWheels
方法。图 6-6 显示程序输出正确。
图 6-6 。显示多重继承与多态性一起工作的输出
七、STL 容器
标准模板库(STL) 由一组要求实现者支持的标准功能组成。创建标准可以确保代码可以在不同的平台和操作系统上互换使用,只要所提供的实现符合该标准。该标准的很大一部分定义了一组可用于存储数据结构的容器。本章着眼于不同的场景,每个 STL 容器都被证明是有用的。
注第三章的中提到了字符串容器。
配方 7-1。存储固定数量的对象
问题
您需要在程序中存储固定数量的对象。
解决办法
C++ 提供了可用于此目的的内置数组,然而 STL 数组提供了比其他 STL 容器更灵活的接口。
它是如何工作的
C++ 支持自语言形成以来就存在的内置数组。如果你以前用过 C 或 C++ 编程,这些对你来说会很熟悉。清单 7-1 显示了一个标准的 C 风格数组。
清单 7-1 。一个 C 风格的数组
#include <cinttypes>
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t numberOfElements{ 5 };
int32_t normalArray[numberOfElements]{ 10, 65, 3000, 2, 49 };
for (uint32_t i{ 0 }; i < numberOfElements; ++i)
{
cout << normalArray[i] << endl;
}
return 0;
}
这段代码展示了 C++ 中 C 风格数组的用法。数组包含 5 个整数,main
函数有一个for
循环,用于迭代数组并打印出每个位置的值。也可以使用基于范围的for
循环来迭代 C 风格的数组。清单 7-2 展示了这是如何做到的。
清单 7-2 。对 C 样式数组使用基于范围的 for 循环
#include <cinttypes>
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
const uint32_t numberOfElements{ 5 };
int32_t normalArray[numberOfElements]{ 10, 65, 3000, 2, 49 };
for (auto&& number : normalArray)
{
cout << number << endl;
}
return 0;
}
清单 7-2 中的函数利用基于范围的 for 循环来迭代数组。当您不需要数组的索引值时,这是一个有用的构造。
注意清单 7-2 中循环的基于范围使用了看起来像右值引用的语法。事实并非如此。如果你不确定这段代码是如何工作的,或者不知道左值和右值之间的区别,请阅读第二章。
C 风格的数组在很多情况下都很有用,但是现代 C++ 也提供了另一种版本的数组,可以用于 STL 迭代器和算法。清单 7-3 展示了如何定义 STL array
。
清单 7-3 。使用 STL array
#include <array>
#include <cinttypes>
#include <iostream>
int main(int argc, char* argv[])
{
const uint32_t numberOfElements{ 5 };
std::array<int32_t, numberOfElements> stlArray{ 10, 65, 3000, 2, 49 };
for (uint32_t i = 0; i < numberOfElements; ++i)
{
std::cout << stlArray[i] << std::endl;
}
for (auto&& number : stlArray)
{
std::cout << number << std::endl;
}
return 0;
}
清单 7-3 显示了 STL array
是通过将存储在array
中的类型和它包含的元素数量传递到类型模板中来定义的。一旦定义了array
,它就可以和普通的 C 风格数组互换使用。这是因为基于范围的 for 循环可以迭代两种类型的数组,并且 STL 数组定义了一个数组操作符重载,允许使用[]
访问元素。
注意与 C 风格的数组相比,使用 STL 数组容器的主要优点是它允许访问 STL 迭代器和算法,这两者都在第八章中有所涉及。
数组将它们的对象存储在连续的内存块中。这意味着每个数组元素的地址在内存中是相邻的。这使得它们在现代处理器上的迭代非常有效。阵列通常会带来出色的高速缓存一致性,因此当处理器从 RAM 读取数据到本地高速缓存时,会导致较少的暂停。对于性能至关重要并且需要固定数量的对象的算法来说,数组是最佳选择。
配方 7-2。存储越来越多的对象
问题
有时候你在编译时不知道需要在数组中存储多少对象。
解决办法
STL 提供了允许动态增长数组的向量模板。
它是如何工作的
vector
的工作方式与array
非常相似。清单 7-4 显示了一个vector
的定义和两种类型的for
循环。
清单 7-4 。使用 STL vector
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> stlVector{ 10, 65, 3000, 2, 49 };
for (uint32_t i = 0; i < stlVector.size(); ++i)
{
std::cout << stlVector[i] << std::endl;
}
for (auto&& number : stlVector)
{
std::cout << number << endl;
}
return 0;
}
vector
和array
的定义之间的主要区别是缺少尺寸。由于vector
是可调整大小的,因此限制它可以包含的元素数量没有什么意义。这在main
函数中的传统 for 循环中得到了体现。您可以看到,循环结束条件通过比较索引和从size
方法返回的值来检查完成情况。在这种情况下,size
将返回 5,因为vector
包含 5 个元素。
清单 7-5 让您看到vector
可以在运行时调整大小,不像array
。
清单 7-5 。调整矢量的大小
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> stlVector{ 10, 65, 3000, 2, 49 };
cout << "The size is: " << stlVector.size() << endl;
stlVector.emplace_back( 50 );
cout << "The size is: " << stlVector.size() << endl;
for (auto&& number : stlVector)
{
std::cout << number << endl;
}
return 0;
}
清单 7-5 的结果输出如图图 7-1 所示。
图 7-1 。清单 7-5 中的生成的输出显示了一个不断增长的vector
图 7-1 显示在调用emplace_back
后vector
从 5 号增长到 6 号。基于循环的范围打印出存储在vector
中的所有值。你可以看到emplace_back
已经把值加到了vector
的末尾。
一个vector
调整大小的方式是实现定义的,这意味着它取决于创建你所使用的库的供应商。所有的实现都使用相似的方法。它们通常倾向于在内部为新的array
分配内存,包括vector
的当前大小以及新值的可变数量的空槽。清单 7-6 包含了使用capacity
方法来决定vector
在调整大小之前能够存储多少元素的代码。
清单 7-6 。调整大小vector
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> stlVector
{
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16
};
cout << "The size is: " << stlVector.size() << endl;
cout << "The capacity is: " << stlVector.capacity() << endl;
stlVector.emplace_back(17);
cout << "The size is: " << stlVector.size() << endl;
cout << "The capacity is: " << stlVector.capacity() << endl;
for (auto&& number : stlVector)
{
std::cout << number << std::endl;
}
return 0;
}
清单 7-6 中的代码创建了一个包含 16 个元素的向量。图 7-2 显示了添加新元素对vector
容量的影响。
图 7-2 。显示使用 Microsoft Visual Studio 2013 STL 时增加的容量的输出
图 7-2 显示给一个vector
增加一个值并不会导致一个元素大小的增加。微软已经决定,他们的 STL 实现将把vector
的容量增加 50%。向大小为 16 的vector
添加新元素会在添加单个新元素时增加 8 个新元素的容量。
也可以在vector
中除了结尾以外的地方添加元素。清单 7-7 展示了如何将emplace
方法用于此目的。
清单 7-7 。向 a vector
中的任意点添加元素
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> stlVector
{
1,
2,
3,
4,
5
};
auto iterator = stlVector.begin() + 2;
stlVector.emplace(iterator, 6);
for (auto&& number : stlVector)
{
std::cout << number << std::endl;
}
return 0;
}
清单 7-7 使用迭代器将值 6 放入向量的第 3 rd 位置。如有必要,此操作将增加 vector 的容量,并将该位置之后的所有元素向右移动一位。图 7-3 显示了该操作的输出。
图 7-3 。清单 7-7 中的输出显示了插入到vector
中第 3 个 rd 位置的元素
也可以从向量中移除元素。清单 7-8 显示了使用迭代器删除vector
的每个元素到最后一个元素的代码。
清单 7-8 。从向量中移除元素
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> stlVector
{
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16
};
cout << "The size is: " << stlVector.size() << endl;
cout << "The capacity is: " << stlVector.capacity() << endl << endl;
for (auto&& number : stlVector)
{
std::cout << number << ", ";
}
while (stlVector.size() > 0)
{
auto iterator = stlVector.end() - 1;
stlVector.erase(iterator);
}
cout << endl << endl << "The size is: " << stlVector.size() << endl;
cout << "The capacity is: " << stlVector.capacity() << endl << endl;
for (auto&& number : stlVector)
{
std::cout << number << ", ";
}
std::cout << std::endl;
return 0;
}
清单 7-8 中的main
函数中的 while 循环逐个擦除vector
中的每个元素。这将改变矢量的大小,但不会改变容量。清单 7-9 增加了代码来减少vector
的容量。
清单 7-9 。减少一个vector
的容量
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> stlVector
{
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16
};
while (stlVector.size() > 0)
{
auto iterator = stlVector.end() - 1;
stlVector.erase(iterator);
if ((stlVector.size() * 2) == stlVector.capacity())
{
stlVector.shrink_to_fit();
}
cout << "The size is: " << stlVector.size() << endl;
cout << "The capacity is: " << stlVector.capacity() << endl << endl;
}
return 0;
}
当while
循环删除元素时,它也会检查vector
的大小何时达到容量的一半。当这个条件满足时,调用shrink_to_fit
方法。图 7-4 显示了shrink_to_fit
对vector
容量的影响。
图 7-4 。shrink_to_fit
对vector's
容量的影响
调整vector
的大小,无论是向上还是向下,都要付出性能代价。必须分配新的内存,并且必须将内部数组中的元素从一个转移到另一个。在这种情况下,建议做两件事:
- 计算出运行时可以添加到
vector
中的元素的最大数量,并使用reserve
方法分配一次所需的必要内存量。 - 确定是否可以完全避免使用
vector
而使用array
来创建对象池。这可以通过使用诸如最近最少使用算法之类的方案重用数组中的元素来实现。
配方 7-3。存储一组不断变化的元素
问题
您有一组数据,您将不断地从任意位置输入和移除元素。
解决办法
STL 提供了两个容器,它们提供了从容器中间进行有效插入和删除的功能。这些是list
和forward_list
容器。
它是如何工作的
array
和vector
容器在连续内存中存储元素。这提供了对集合的快速迭代,因为它们发挥了现代 CPU 架构的优势。在运行时,数组容器不能被添加或删除,元素只能被改变。vector
容器可以添加和删除元素,但这需要一个新的内存分配,并将所有元素从旧内存块转移到新内存块。
另一方面,list
容器不在连续的内存块中存储元素。相反,列表中的每个元素都存储在一个独立的节点中,该节点包含一个指向列表中下一个和最后一个元素的指针。这允许在list
容器中双向遍历。一个forward_list
只存储指向下一个元素的指针,而不是最后一个,因此只能从前到后遍历。在更新引用列表结构中下一个和最后一个节点的指针时,在列表中添加和删除元素变成了一项琐碎的工作。
这种不连续的存储导致遍历列表时性能下降。CPU 缓存不能总是预加载列表中的下一个元素,因此对于定期遍历的数据集,应该避免使用这些结构。它们的优势来自于节点的快速插入和删除。清单 7-10 显示了一个正在使用的list
容器。
清单 7-10 。使用list
#include <cinttypes>
#include <iostream>
#include <list>
using namespace std;
int main(int argv, char* argc[])
{
list<int32_t> myList{ 1, 2, 3, 4, 5 };
myList.emplace_front(6);
myList.emplace_back(7);
auto forwardIter = myList.begin();
++forwardIter;
++forwardIter;
myList.emplace(forwardIter, 9);
auto reverseIter = myList.end();
--reverseIter;
--reverseIter;
--reverseIter;
myList.emplace(reverseIter, 8);
for (auto&& number : myList)
{
cout << number << endl;
}
return 0;
}
清单 7-10 的 main 函数中使用的列表容器允许从 begin 或 end 返回的迭代器向前和向后遍历。图 7-5 包含了遍历list
生成的输出,在这里你可以看到添加元素的任意顺序。
图 7-5 。遍历清单 7-10 中容器时的输出
清单 7-11 显示了与forward_
list
类似的代码
清单 7-11 。使用forward_list
#include <cinttypes>
#include <forward_list>
#include <iostream>
using namespace std;
int main(int argv, char* argc[])
{
forward_list<int32_t> myList{ 1, 2, 3, 4, 5 };
myList.emplace_front(6);
auto forwardIter = myList.begin();
++forwardIter;
++forwardIter;
myList.emplace_after(forwardIter, 9);
for (auto&& number : myList)
{
cout << number << endl;
}
return 0;
}
与清单 7-10 中的相比,清单 7-11 中的有一些不同。一个forward_list
不包含方法emplace
或者emplace_back
。它确实包含了emplace_front
和emplace_after
,允许你在forward_list
的开头或forward_list
的特定位置之后添加元素。
配方 7-4。将排序后的对象存储在支持快速查找的容器中
问题
您有一个很大的对象集合,您希望对其进行排序,并且经常需要查找特定的信息。
解决办法
STL 提供了set
和map
容器,它们可以自动对它们的对象进行排序,并提供非常快速的搜索特性。
它是如何工作的
set
和map
容器是关联容器。这意味着它们将其数据元素与一个键相关联。在set
的情况下,键是对象或值本身,而对于map
来说,键是与对象或值一起提供的值。
这些容器是使用二分搜索法树实现的,这也是它们提供自动排序和快速搜索特性的原因。二分搜索法树通过比较对象的键来运行。如果一个对象的键小于当前节点的键,那么它被添加到左边,如果它大于当前节点的键,那么它被添加到右边,反之亦然。
注意事实上你可以为两个容器提供一个函数,允许你为自己指定排序顺序。
清单 7-12 展示了一个set
的创建,它将元素从最小到最大排序。
清单 7-12 。使用set
#include <cinttypes>
#include <iostream>
#include <set>
#include <string>
using namespace std;
class SetObject
{
private:
string m_Name;
int32_t m_Key{};
public:
SetObject(int32_t key, const string& name)
: m_Name{ name }
, m_Key{ key }
{
}
SetObject(int32_t key)
: SetObject(key, "")
{
}
const string& GetName() const
{
return m_Name;
}
int32_t GetKey() const
{
return m_Key;
}
bool operator<(const SetObject& other) const
{
return m_Key < other.m_Key;
}
bool operator>(const SetObject& other) const
{
return m_Key > other.m_Key;
}
};
int main(int argv, char* argc[])
{
set<SetObject> mySet
{
{ 6, "Six" },
{ 3, "Three" },
{ 4, "Four" },
{ 1, "One" },
{ 2, "Two" }
};
for (auto&& number : mySet)
{
cout << number.GetName() << endl;
}
auto iter = mySet.find(3);
if (iter != mySet.end())
{
cout << "Found: " << iter->GetName() << endl;
}
return 0;
}
清单 7-12 的main
函数中定义的set
用五个SetObject
实例初始化。每个实例都存储一个整数键和该键的一个string
表示。默认情况下,一个set
被初始化为从低到高排列它包含的元素。你可以在图 7-6 中看到这一点。
图 7-6 。由清单 7-12 中的代码生成的输出
类对象的排序是使用运算符重载实现的。SetObject
类重载了<
和>
操作符,这使得该类可以和这些操作符一起使用。当添加一个新元素时,set
将调用一个比较函数来决定元素在set
中出现的顺序。default case
要求在元素上使用<
操作符。正如你所看到的,SetObject 类比较了操作符中的m_Key
变量来决定它们应该被存储的顺序。
清单 7-13 展示了如何改变默认的set
来从最高到最低排列元素。
清单 7-13 。从最高到最低对 a set
中的元素进行排序
#include <cinttypes>
#include <functional>
#include <iostream>
#include <set>
#include <string>
using namespace std;
class SetObject
{
private:
string m_Name;
int32_t m_Key{};
public:
SetObject(int32_t key, const string& name)
: m_Name{ name }
, m_Key{ key }
{
}
SetObject(int32_t key)
: SetObject(key, "")
{
}
const string& GetName() const
{
return m_Name;
}
int32_t GetKey() const
{
return m_Key;
}
bool operator<(const SetObject& other) const
{
return m_Key < other.m_Key;
}
bool operator>(const SetObject& other) const
{
return m_Key > other.m_Key;
}
};
using namespace std;
int main(int argv, char* argc[])
{
set<SetObject, greater<SetObject>> mySet
{
{ 6, "Six" },
{ 3, "Three" },
{ 4, "Four" },
{ 1, "One" },
{ 2, "Two" }
};
for (auto&& number : mySet)
{
cout << number.GetName() << endl;
}
auto iter = mySet.find(3);
if (iter != mySet.end())
{
cout << "Found: " << iter->GetName() << endl;
}
return 0;
}
清单 7-12 中的和清单 7-13 中的的唯一区别是在set
中增加了第二个模板参数。清单 7-13 从功能标题提供greater
模板。该模板将从一个函数中创建一个方法,该函数可以在两个SetObject
实例上调用>操作符。您可以想象默认的set
有一个隐含的less
参数:
set<SetObject, less<SetObject>>
图 7-7 显示了一个set
的结果输出,元素从最高到最低排序。
图 7-7 。使用greater
将set
从最高到最低排序
清单 7-14 展示了如何在初始化后给一个set
添加元素。
清单 7-14 。向set
添加元素
#include <cinttypes>
#include <functional>
#include <iostream>
#include <set>
#include <string>
using namespace std;
class SetObject
{
private:
string m_Name;
int32_t m_Key{};
public:
SetObject(int32_t key, const string& name)
: m_Name{ name }
, m_Key{ key }
{
}
SetObject(int32_t key)
: SetObject(key, "")
{
}
const string& GetName() const
{
return m_Name;
}
int32_t GetKey() const
{
return m_Key;
}
bool operator<(const SetObject& other) const
{
return m_Key < other.m_Key;
}
bool operator>(const SetObject& other) const
{
return m_Key > other.m_Key;
}
};
int main(int argv, char* argc[])
{
set<SetObject, greater<SetObject>> mySet
{
{ 6, "Six" },
{ 3, "Three" },
{ 4, "Four" },
{ 1, "One" },
{ 2, "Two" }
};
for (auto&& number : mySet)
{
cout << number.GetName() << endl;
}
cout << endl;
mySet.emplace(SetObject( 5, "Five" ));
for (auto&& number : mySet)
{
cout << number.GetName() << endl;
}
cout << endl;
auto iter = mySet.find(3);
if (iter != mySet.end())
{
cout << "Found: " << iter->GetName() << endl;
}
return 0;
}
emplace
方法可以用来给set
添加新元素,如清单 7-14 所示。图 7-8 显示新元素被插入set
中给定greater
顺序的正确位置。
图 7-8 。显示一个新元素已经被添加到set
的正确位置
除了键的存储独立于对象值之外,map
容器与set
容器非常相似。清单 7-15 显示了创建一个map
容器的代码。
清单 7-15 。创建一个map
#include <cinttypes>
#include <functional>
#include <iostream>
#include <map>
#include <string>
using namespace std;
class MapObject
{
private:
string m_Name;
public:
MapObject(const string& name)
: m_Name{ name }
{
}
const string& GetName() const
{
return m_Name;
}
};
int main(int argv, char* argc[])
{
map<int32_t, MapObject, greater<int32_t>> myMap
{
pair<int32_t, MapObject>(6, MapObject("Six")),
pair<int32_t, MapObject>(3, MapObject("Three")),
pair<int32_t, MapObject>(4, MapObject("Four")),
pair<int32_t, MapObject>(1, MapObject("One")),
pair<int32_t, MapObject>(2, MapObject("Two"))
};
for (auto&& number : myMap)
{
cout << number.second.GetName() << endl;
}
cout << endl;
myMap.emplace(pair<int32_t, MapObject>(5, MapObject("Five")));
for (auto&& number : myMap)
{
cout << number.second.GetName() << endl;
}
cout << endl;
auto iter = myMap.find(3);
if (iter != myMap.end())
{
cout << "Found: " << iter->second.GetName() << endl;
}
return 0;
}
清单 7-15 使用map
代替set
获得了与清单 7-14 中的代码完全相同的结果。MapObject
类不包含键,也不包含任何重载操作符来比较使用该类实例化的对象。这是因为map
的键是独立于数据存储的。使用pair
模板将元素添加到map
中,每个pair
将一个键值关联到一个对象。
一个map
的代码比一个set
的代码更冗长,但是包含的对象可以不那么复杂。当键与类中的其他数据不相关时,map
比set
更适合。具有自然顺序并且已经具有可比性的对象是存储在set
中的良好候选对象。
一个map
的迭代器也是一个pair
。它包含的MapObject
可以使用iterator
上的second
字段检索,同时首先存储键值。在map
或set
上迭代是一个缓慢的操作,因为元素不包含在连续的存储器中。关联容器的好处主要是它们的快速查找,而排序是次要的好处,出于性能原因应该尽量少用。
配方 7-5。将未排序的元素存储在容器中,以便快速查找
问题
您有一组不需要排序但将用于频繁查找和数据检索的数据。
解决办法
STL 为此提供了unordered_set
和unordered_map
容器。
它是如何工作的
unordered_set
和unordered_map
容器被实现为哈希映射。哈希映射提供了对象的固定时间插入、移除和搜索。恒定时间意味着无论容器中有多少元素,操作都将花费相同的时间。
由于unordered_set
和unordered_map
容器是散列映射,它们依赖于提供的散列函数,该函数可以将数据转换成数值。清单 7-16 展示了如何创建一个集合来存储可以被散列和比较的用户定义的类。
清单 7-16 。使用unordered_
set
#include <cinttypes>
#include <functional>
#include <iostream>
#include <string>
#include <unordered_set>
using namespace std;
class SetObject;
namespace std
{
template <>
class hash<SetObject>
{
public:
template <typename... Args>
size_t operator()(Args&&... setObject) const
{
return hash<string>()((forward<Args...>(setObject...)).GetName());
}
};
}
class SetObject
{
private:
string m_Name;
size_t m_Hash{};
public:
SetObject(const string& name)
: m_Name{ name }
, m_Hash{ hash<SetObject>()(*this) }
{
}
const string& GetName() const
{
return m_Name;
}
const size_t& GetHash() const
{
return m_Hash;
}
bool operator==(const SetObject& other) const
{
return m_Hash == other.m_Hash;
}
};
int main(int argv, char* argc[])
{
unordered_set<SetObject> mySet;
mySet.emplace("Five");
mySet.emplace("Three");
mySet.emplace("Four");
mySet.emplace("One");
mySet.emplace("Two");
cout << showbase << hex;
for (auto&& number : mySet)
{
cout << number.GetName() << " - " << number.GetHash() << endl;
}
auto iter = mySet.find({ "Three" });
if (iter != mySet.end())
{
cout << "Found: " << iter->GetName() << " with hash: " << iter->GetHash() << endl;
}
return 0;
}
使用一个unordered_set
来存储类对象需要一些难以理解的代码。首先,我们对hash
模板进行了部分专门化。这允许我们创建一个能够为SetObject
类创建哈希值的函数。这是通过传递一个SetObject
实例并为string
调用 STL hash
函数来实现的。使用通用引用和转发功能将SetObject
实例传递给()
操作符,以实现完美转发。
注意模板包含在第九章的中,通用引用与左值、右值和完全转发包含在第二章的中。
SetObject
类需要重载的==
操作符才能在unordered_set
中正常运行。如果缺少这一项,代码将无法编译。成员变量m_Hash
是不需要的,我只是把它包含进来,向您展示hash
创建的值,以及您如何为自己调用散列函数。如果m_Hash
变量不存在,您可以比较m_Name
字符串是否相等。图 7-9 显示了该代码生成的结果输出。
图 7-9 。清单 7-16 生成的输出
只要你使用 STL 已经可以散列的键的类型,创建你自己的散列函数并不困难。清单 7-17 显示了一个使用整数作为键的unordered_map
。
清单 7-17 。使用unordered_map
#include <cinttypes>
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
class MapObject
{
private:
string m_Name;
public:
MapObject(const string& name)
: m_Name{ name }
{
}
const string& GetName() const
{
return m_Name;
}
};
int main(int argv, char* argc[])
{
unordered_map<int32_t, MapObject> myMap;
myMap.emplace(pair<int32_t, MapObject>(5, MapObject("Five")));
myMap.emplace(pair<int32_t, MapObject>(3, MapObject("Three")));
myMap.emplace(pair<int32_t, MapObject>(4, MapObject("Four")));
myMap.emplace(pair<int32_t, MapObject>(1, MapObject("One")));
myMap.emplace(pair<int32_t, MapObject>(2, MapObject("Two")));
cout << showbase << hex;
for (auto&& number : myMap)
{
cout << number.second.GetName() << endl;
}
auto iter = myMap.find(3);
if (iter != myMap.end())
{
cout << "Found: " << iter->second.GetName() << endl;
}
return 0;
}
清单 7-17 显示了unordered_map
容器存储键值对作为它的元素。pair
的第一个字段存储键,而pair
的第二个字段存储值,在本例中是MapObject
的一个实例。
八、STL 算法
STL 提供了一套算法,可以和它提供的容器一起使用。这些算法都使用迭代器。迭代器是一种抽象机制,允许遍历许多不同的 STL 集合。本章包括迭代器和一些不同的算法以及它们的用途。
配方 8-1。使用迭代器定义容器中的序列
问题
你有一个 STL 容器,你想在这个容器中标记一个序列,这个序列在特定的点开始和结束。
解决办法
STL 提供了适用于所有容器的迭代器,可以用来表示容器中序列的开始和结束。该序列可以包括容器中的每个节点,也可以包括容器中节点的子集。
它是如何工作的
迭代器的工作方式与指针相似。它们的语法非常相似。你可以在清单 8-1 中看到迭代器的使用。
清单 8-1 。使用带有vector
和的iterator
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int arcg, char* argv[])
{
using IntVector = vector<int32_t>;
using IntVectorIterator = IntVector::iterator;
IntVector myVector{ 0, 1, 2, 3, 4 };
for (IntVectorIterator iter = myVector.begin(); iter != myVector.end(); ++iter)
{
cout << "The value is: " << *iter << endl;
}
return 0;
}
在清单 8-1 中的main
函数中创建了一个int
类型的vector
。一个类型别名被用来制作一个新类型的IntVector
来表示这种类型的集合。第二个别名用于表示这个集合使用的iterator
的类型。可以看到iterator
类型是通过初始的vector
类型访问的。这是必要的,因为iterator
也必须操作与矢量本身操作相同类型的对象。在 vector 类型中包含迭代器类型允许您指定要操作的类型,在本例中是int32 _t
,同时用于两者。
iterator
类型用于在for
循环中获取对myVector
集合的开始和结束的引用。向量返回迭代器的begin
和end
方法。如果表示集合开始的iterator
等于表示集合结束的迭代器,则称该集合为空。这是iterators
与指针共有的第一个属性,它们是可比较的。
for 循环中的iter
变量被初始化为由vector::begin
方法返回的值。执行for
循环,直到 iter 变量等于由vector::end
方法 返回的iterator
。这说明集合中的值序列可以用两个iterators
来表示,一个在序列的开头,一个在序列的结尾。一个iterator
提供了一个增量操作符,允许iterator
移动到序列中的下一个元素。这就是如何将 for 循环中的iter
变量初始化为由begin
返回的iterator
,并针对end
进行测试,直到序列遍历完成。这也恰好是iterators
与指针共享的另一个属性,递增或递减会将迭代器移动到序列中的下一个或最后一个元素。
注意不是所有的迭代器都支持递增和递减操作。在下面的段落中,您将会看到这种情况。
用iterator
覆盖的最后一个重要操作是解引用操作符。你可能在标准指针操作中熟悉这些,这是迭代器与指针共享的最后一个属性。从清单 8-1 中可以看到,解引用操作符用于检索由iterator
表示的值。在本例中,解引用用于从集合中检索每个迭代器,并将其发送到控制台。图 8-1 表明情况就是如此。
图 8-1 。当myVector
集合被遍历时清单 8-1 的输出
试图在不使用解引用操作符的情况下打印出iterator
会导致编译错误,因为cout::<<
操作符不支持iterator
类型。
清单 8-1 中的代码使用了标准的正向迭代器。这种迭代器为容器中的每个元素提供非常量访问。清单 8-2 显示了这个属性的含义。
清单 8-2 。使用非常数迭代器
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int arcg, char* argv[])
{
using IntVector = vector<int32_t>;
using IntVectorIterator = IntVector::iterator;
IntVector myVector(5, 0);
int32_t value{ 0 };
for (IntVectorIterator iter = myVector.begin(); iter != myVector.end(); ++iter)
{
*iter = value++;
}
for (IntVectorIterator iter = myVector.begin(); iter != myVector.end(); ++iter)
{
cout << "The value is: " << *iter << endl;
}
return 0;
}
如果你将清单 8-2 与清单 8-1 进行比较,你会发现myVector
集合的初始化是以不同的方式处理的。清单 8-2 初始化vector
以包含值 0 的 5 个副本。然后一个for
循环遍历vector
,并使用iterator
解引用操作符将递增后的值变量分配给myVector
中的每个位置。由于iterator
类型的非常数性质,这是可能的。如果你想使用一个iterator
,你知道它不应该有写权限,那么你可以使用一个const_iterator
,如清单 8-3 所示。
清单 8-3 。使用const_iterator
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int arcg, char* argv[])
{
using IntVector = vector<int32_t>;
using IntVectorIterator = IntVector::iterator;
using ConstIntVectorIterator = IntVector::const_iterator;
IntVector myVector(5, 0);
int32_t value{ 0 };
for (IntVectorIterator iter = myVector.begin(); iter != myVector.end(); ++iter)
{
*iter = value++;
}
for (ConstIntVectorIterator iter = myVector.cbegin(); iter != myVector.cend(); ++iter)
{
cout << "The value is: " << *iter << endl;
}
return 0;
}
清单 8-3 在第二个for
循环中使用vector::cbegin
和vector::cend
方法来获得对myVector
元素的访问,但不提供写访问。任何试图给const_iterator
赋值的行为都会导致编译错误。C++ 集合提供的iterator
和const_iterator
类型都是正向迭代器的例子。这意味着它们都按照您可能会想到的顺序从头到尾遍历集合。STL 集合也支持reverse_iterator
和const_reverse_iterator
类型。这些允许你向后遍历你的序列。清单 8-4 显示了使用reverse_itertor
从最高到最低初始化myVector
集合。
清单 8-4 。使用reverse_iterator
初始化myVector
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int arcg, char* argv[])
{
using IntVector = vector<int32_t>;
using IntVectorIterator = IntVector::iterator;
using ConstIntVectorIterator = IntVector::const_iterator;
using ReverseIntVectorIterator = IntVector::reverse_iterator;
using ConstReverseIntVectorIterator = IntVector::const_reverse_iterator;
IntVector myVector(5, 0);
int32_t value { 0 };
for (ReverseIntVectorIterator iter = myVector.rbegin(); iter != myVector.rend(); ++iter)
{
*iter = value++;
}
for (ConstIntVectorIterator iter = myVector.cbegin(); iter != myVector.cend(); ++iter)
{
cout << "The value is: " << *iter << endl;
}
return 0;
}
清单 8-4 显示reverse_iterator
应该与vector
提供的rbegin
和rend
方法一起使用。递增一个reverse_iterator
会导致它在集合中向后移动。图 8-2 显示myVector
集合已经以相反的顺序存储了值。
图 8-2 。从myVector
开始按相反顺序取值
图 8-2 中的输出也可以使用清单 8-5 中的代码来实现,该代码使用一个const_reverse_iterator
来打印数值。
清单 8-5 。使用const_reverse_iterator
反向打印myVector
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int arcg, char* argv[])
{
using IntVector = vector<int32_t>;
using IntVectorIterator = IntVector::iterator;
using ConstIntVectorIterator = IntVector::const_iterator;
using ReverseIntVectorIterator = IntVector::reverse_iterator;
using ConstReverseIntVectorIterator = IntVector::const_reverse_iterator;
IntVector myVector(5, 0);
int32_t value{ 0 };
for (IntVectorIterator iter = myVector.begin(); iter != myVector.end(); ++iter)
{
*iter = value++;
}
for (ConstReverseIntVectorIterator iter = myVector.crbegin();
iter != myVector.crend();
++iter)
{
cout << "The value is: " << *iter << endl;
}
return 0;
}
清单 8-5 使用const_reverse_iterator
以及crbegin
和crend
方法从最后到第一步遍历集合,并以相反的顺序打印值。
迭代器将在本章的剩余部分扮演重要的角色,因为它们被用作 STL 提供的算法的输入。
食谱 8-2。对容器中的每个元素调用函数
问题
你有一个容器,想要一个简单的方法来调用每个元素的函数。
解决办法
STL 提供了for_each
函数 ,它采用一个开始迭代器、一个结束迭代器和一个函数来调用两者之间的每个元素。
它是如何工作的
for_each
函数可以传递两个迭代器。这些迭代器定义了容器中应该被遍历的起点和终点。3 rd 参数是一个应该为每个元素调用的函数。元素本身被传递到函数中。清单 8-6 显示了for_each
函数的用法。
清单 8-6 。for_each
算法
#include <algorithm>
#include <cinttypes>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int32_t> myVector
{
1,
2,
3,
4,
5
};
for_each(myVector.begin(), myVector.end(),
[](int32_t value)
{
cout << value << endl;
});
return 0;
}
清单 8-6 中的代码创建了一个包含 5 个元素的vector
,数字 1 到 5。向for_each
函数传递由begin
和end
方法返回的迭代器,以定义应该传递给参数 3 中提供的函数的值的范围。参数 3 是未命名的函数或 lambda。
lambda 的方括号表示捕获列表。这个列表用于允许 lambda 访问存在于创建它的函数中的变量。在这种情况下,我们没有从函数中捕获任何变量。括号表示参数列表。清单 8-1 中的 lambda 将一个int32_t
作为参数,因为它是存储在vector
中的类型。花括号表示函数体,就像它们表示标准函数体一样。执行这段代码会产生如图图 8-3 所示的输出。
图 8-3 。清单 8-6 中的for_each
和生成的输出
生成此输出是因为for_each
算法将来自myVector
中每个位置的整数传递给所提供的函数,在本例中是一个 lambda。
食谱 8-3。查找容器中的最大值和最小值
问题
偶尔你会想找出容器中的最大值或最小值。
解决办法
STL 提供了允许你在 STL 容器中找到最大和最小值的算法。这些是min_element
和max_element
功能。
它是如何工作的
寻找容器中的最小值
min_element
功能通过在给定序列的开头和结尾放置一个iterator
来运行。它遍历该序列,并找到该序列中包含的最小值。清单 8-7 展示了这个算法的使用。
清单 8-7 。使用最小元素算法
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int> myVector{ 4, 10, 6, 9, 1 };
auto minimum = min_element(myVector.begin(), myVector.end());
cout << "Minimum value: " << *minimum << std::endl;
return 0;
}
在这种情况下,您可以看到一个vector
被用来存储integer
元素。向min_element
函数传递的iterator
表示vector
包含的序列的开始和结束。该算法向包含最小值的元素返回一个iterator
。我在这里使用auto
是为了避免写出整个迭代器的类型(应该是vector<int>::iterator
)。很明显,当查看输出值的行时,返回的是迭代器。从迭代器中检索integer
值需要指针解引用操作符。您可以在图 8-4 中看到代码生成的输出。
图 8-4 。来自清单 8-7 的输出显示了检索到的最小值
清单 8-7 中的容器显示了一个容器存储整数值的简单例子。这种情况是微不足道的,因为两个int
变量已经可以使用<
操作符进行比较。通过在你的类中提供一个重载的<
操作符,你可以在你自己的类中使用min_element
。你可以在清单 8-8 中看到这样的例子。
清单 8-8 。将min_element
与包含<
操作符的class
结合使用
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
class MyClass
{
private:
int m_Value;
public:
MyClass(const int value)
: m_Value{ value }
{
}
int GetValue() const
{
return m_Value;
}
bool operator <(const MyClass& other) const
{
return m_Value < other.m_Value;
}
};
int main(int argc, char* argv[])
{
vector<MyClass> myVector{ 4, 10, 6, 9, 1 };
auto minimum = min_element(myVector.begin(), myVector.end());
if (minimum != myVector.end())
{
cout << "Minimum value: " << (*minimum).GetValue() << std::endl;
}
return 0;
}
清单 8-7 和 10-8 的不同之处在于使用了MyClass
对象的vector
而不是integer
值的vector
。然而,对min_element
的呼叫仍然完全一样。在这种情况下,min_element
调用将遍历序列,并使用添加到MyClass class
的<
操作符来查找最小值。在这种情况下,防止碰到序列的结尾也是必要的,因为 end 元素不会指向有效的对象,因此对GetValue
的解引用和调用可能会崩溃。
比较非基本类型的另一个选择是直接向min_element
函数提供一个比较函数。该选项如清单 8-9 中的所示。
清单 8-9 。使用带有 min_element 的独立函数
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
class MyClass
{
private:
int m_Value;
public:
MyClass(const int value)
: m_Value{ value }
{
}
int GetValue() const
{
return m_Value;
}
};
bool CompareMyClasses(const MyClass& left, const MyClass& right)
{
return left.GetValue() < right.GetValue();
}
int main(int argc, char* argv[])
{
vector<MyClass> myVector{ 4, 10, 6, 9, 1 };
auto minimum = min_element(myVector.begin(), myVector.end(), CompareMyClasses);
if (minimum != myVector.end())
{
cout << "Minimum value: " << (*minimum).GetValue() << std::endl;
}
return 0;
}
在清单 8-9 中,我们为min_element
函数提供了一个指向比较函数的指针。该函数用于比较从MyClass GetValue
方法返回的值。比较函数 是以一种非常特殊的方式构造的,它有两个参数,都是对MyClass
对象的常量引用。如果第一个参数被评估为小于第二个参数,该函数应该返回true
。选择名称left
和right
是为了帮助形象化<
操作员的通常外观。对min_element
的调用被修改为包含第三个参数,即指向CompareMyClasses
函数的指针。清单 10-8 和清单 10-9 中显示的代码产生的输出与图 8-4 中显示的输出相同。
寻找容器中的最大值
min_element
函数可用于查找序列中的最小值,而max_element
函数可用于查找最大值。该函数的使用方式与min_element
函数完全相同,如清单 8-10 中的所示。
清单 8-10 。使用max_element
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
class MyClass
{
private:
int m_Value;
public:
MyClass(const int value)
: m_Value{ value }
{
}
int GetValue() const
{
return m_Value;
}
bool operator <(const MyClass& other) const
{
return m_Value < other.m_Value;
}
};
bool CompareMyClasses(const MyClass& left, const MyClass& right)
{
return left.GetValue() < right.GetValue();
}
int main(int argc, char* argv[])
{
vector<int> myIntVector{ 4, 10, 6, 9, 1 };
auto intMinimum = max_element(myIntVector.begin(), myIntVector.end());
if (intMinimum != myIntVector.end())
{
cout << "Maxmimum value: " << *intMinimum << std::endl << std::endl;
}
vector<MyClass> myMyClassVector{ 4, 10, 6, 9, 1 };
auto overrideOperatorMinimum = max_element(myMyClassVector.begin(),
myMyClassVector.end());
if (overrideOperatorMinimum != myMyClassVector.end())
{
cout << "Maximum value: " << (*overrideOperatorMinimum).GetValue() <<
std::endl << std::endl;
}
auto functionComparisonMinimum = max_element(myMyClassVector.begin(),
myMyClassVector.end(),
CompareMyClasses);
if (functionComparisonMinimum != myMyClassVector.end())
{
cout << "Maximum value: " << (*functionComparisonMinimum).GetValue() <<
std::endl << std::endl;
}
return 0;
}
清单 8-10 显示了max_element
函数可以用来代替min_element
函数。认识到max_element
函数仍然使用<
操作符是很重要的。看起来,max_element
可能会使用>
操作符,但是使用<
操作符并响应false
而不是true
的结果来表明一个值大于另一个值也是有效的。
食谱 8-4。对序列中某个值的实例计数
问题
有时您可能希望知道一个序列中有多少个特定值的实例。
解决办法
STL 提供了一种叫做count
的算法。该算法可以搜索一系列值,并返回找到所提供值的次数。
它是如何工作的
count
函数有 3 个参数,一个开始参数iterator
,一个结束参数iterator
和一个要查找的值。给定这三条信息,算法将返回该值出现的次数。清单 8-11 展示了这个算法的使用。
清单 8-11 。使用计数算法
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int> myVector{ 3, 2, 3, 7, 3, 8, 9, 3 };
auto number = count(myVector.begin(), myVector.end(), 3);
cout << "The number of 3s in myVector is: " << number << endl;
return 0;
}
清单 8-11 中的代码将让count
函数遍历序列并返回遇到值 3 的次数。在图 8-5 中可以看到这个操作的结果是 4。
图 8-5 。由生成的结果输出见清单 8-11
C++ 还提供了一些特殊的谓词函数,可以与字符数据和count_if
函数结合使用。这些函数可以用来计算大写或小写字母的数量,以及字符是字母数字、空格还是标点符号。你可以在清单 8-12 中看到所有这些。
清单 8-12 。使用带有count
的字符谓词
#include <algorithm>
#include <cctype>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[])
{
string myString{ "Bruce Sutherland!" };
auto numberOfCapitals = count_if(
myString.begin(),
myString.end(),
[](auto&& character)
{
return static_cast<bool>(isupper(character));
});
cout << "The number of capitals: " << numberOfCapitals << endl;
auto numberOfLowerCase = count_if(
myString.begin(),
myString.end(),
[](auto&& character)
{
return static_cast<bool>(islower(character));
});
cout << "The number of lower case letters: " << numberOfLowerCase << endl;
auto numberOfAlphaNumerics = count_if(
myString.begin(),
myString.end(),
[](auto&& character)
{
return static_cast<bool>(isalpha(character));
});
cout << "The number of alpha numeric characters: " << numberOfAlphaNumerics << endl;
auto numberOfPunctuationMarks = count_if(
myString.begin(),
myString.end(),
[](auto&& character)
{
return static_cast<bool>(ispunct(character));
});
cout << "The number of punctuation marks: " << numberOfPunctuationMarks << endl;
auto numberOfWhiteSpaceCharacters = count_if(
myString.begin(),
myString.end(),
[](auto&& character)
{
return static_cast<bool>(isspace(character));
});
cout << "The number of white space characters: " << numberOfWhiteSpaceCharacters << endl;
return 0;
}
在清单 8-12 中,可以看到谓词使用 lambda 传递给了count_if
函数。lambda 对于count_if
模板来说是必要的,它可以满足被提供的函数是一个返回bool
的谓词。count_if
函数将返回所提供的函数返回true
的次数。您可以在图 8-6 的中看到不同调用count_if
的结果。
图 8-6 。调用清单 8-6 中代码的结果
清单 8-6 中提供的字符串相当简单,因此很容易确认字符谓词是否按预期工作。您可以对照图 8-6 的结果来确认这一点。
配方 8-5。在序列中查找值
问题
您可能希望找到序列中匹配特定值的第一个元素的迭代器。
解决办法
STL 提供了 find 函数来检索序列中匹配给定值的第一个元素的迭代器。
它是如何工作的
find 函数可用于检索与您提供的值匹配的第一个值的迭代器。你可以用它从头到尾地浏览一个序列。清单 8-13 展示了如何使用 while 循环来移动整个序列。
清单 8-13 。使用find
#include <algorithm>
#include <iostream>
#include <string>
using namespace std;
int main(int argc, char* argv[])
{
string myString{ "Bruce Sutherland" };
auto found = find(myString.begin(), myString.end(), 'e');
while (found != myString.end())
{
cout << "Found: " << *found << endl;
found = find(found+1, myString.end(), 'e');
}
return 0;
}
清单 8-13 中的代码将打印出字母 e 两次,因为变量myString
中的string
中有两个字母。对find
的第一次调用返回一个迭代器,指向字符串中字符 e 的第一个实例。然后,while 循环中的调用从紧接该迭代器之后的位置开始。这使得 find 函数逐步搜索所提供的数据集,并最终到达末尾。一旦发生这种情况,while 循环将终止。清单 8-13 中的代码生成如图 8-7 所示的输出。
图 8-7 。执行清单 8-13 中的代码生成的输出
配方 8-6。排序序列中的元素
问题
有时,容器中的数据变得无序,您希望对这些数据进行重新排序。
解决办法
STL 提供了排序算法来对序列中的数据进行重新排序。
它是如何工作的
sort 函数将一个迭代器放在序列的开头,将一个迭代器放在序列的结尾。它会自动将迭代器之间的值按数字升序排序。你可以在清单 8-14 中看到实现这一点的代码。
清单 8-14 。使用sort
算法
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<int> myVector{ 10, 6, 4, 7, 8, 3, 9 };
sort(myVector.begin(), myVector.end());
for (auto&& element : myVector)
{
cout << element << ", ";
}
cout << endl;
return 0;
}
清单 8-14 中的代码将把myVector
中的值按升序重新排序。图 8-8 显示了这段代码产生的输出。
图 8-8 。按升序排序的 myVector 元素
如果您希望按照自定义的顺序对数据进行排序,比如降序,那么您必须为sort
算法提供一个谓词函数。清单 8-15 展示了一个谓词对一个数字vector
进行降序排序的用法。
清单 8-15 。使用带sort
的谓词
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
bool IsGreater(int left, int right)
{
return left > right;
}
int main(int argc, char* argv[])
{
vector<int> myVector{ 10, 6, 4, 7, 8, 3, 9 };
sort(myVector.begin(), myVector.end(), IsGreater);
for (auto&& element : myVector)
{
cout << element << ", ";
}
return 0;
}
清单 8-15 中的 myVector 中的数据与清单 8-14 中存储的数据相同。这两个清单的区别是在清单 8-15 中使用了IsGreater
函数。这被传递给sort
函数,用于比较myVector
中的值。标准排序函数将数值从最低到最高排序,如图 8-9 中的所示。图 8-10 显示清单 8-15 中的代码将把数字从最高到最低排序。
图 8-9 。清单 8-15 生成的输出,数字从最高到最低排序
九、模板
STL 是使用 C++ 提供的一种叫做模板的语言特性编写的。模板提供了一种方法,您可以用它来编写通用代码,这些代码可以在编译时被专门化,以创建具体的函数和不同类型的类。对模板代码的唯一要求是,可以为程序中用于专门化模板的所有类型生成输出。在这一点上,这可能有点难以理解,但是当你读完这一章的时候,你就会明白了。
9-1.创建模板函数
问题
您希望创建一个函数,可以传递不同类型的参数并返回不同类型的值。
解决办法
可以使用方法重载为您希望支持的每种类型提供不同版本的函数,但这仍然会将您限制在所提供类型的函数中。更好的方法是创建一个模板函数,专门用于任何类型。
它是如何工作的
C++ 包括一个模板编译器,可以用来在编译时将通用函数定义转换成具体函数。
创建模板函数
模板允许您在不指定具体类型的情况下编写代码。代码通常包含您希望使用的类型;清单 9-1 显示了在这些正常情况下编写的函数。
清单 9-1 。 非模板功能
#include <iostream>
using namespace std;
int Add(int a, int b)
{
return a + b;
}
int main(int argc, char* argv[])
{
const int number1{ 1 };
const int number2{ 2 };
const int result{ Add(number1, number2) };
cout << "The result of adding" << endl;
cout << number1 << endl;
cout << "to" << endl;
cout << number2 << endl;
cout << "is" << endl;
cout << result;
return 0;
}
清单 9-1 中的Add
函数是一个标准的 C++ 函数。它接受两个int
参数并返回一个int
值。您可以提供这个函数的一个float
版本,方法是复制这个函数并修改每个对int
的引用,以便它使用一个float
来代替。然后,您可以对string
和您希望该函数支持的任何其他类型进行同样的操作。这种方法的问题是,即使函数体保持不变,也必须为每种类型复制函数。另一种解决方案是使用模板函数。你可以在清单 9-2 中看到Add
的模板版本。
清单 9-2 。一个Add
的模板版本
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
可以看到,Add
的模板版本不再使用具体类型int
。相反,该函数是在模板块中定义的。template
关键字用来告诉编译器下一个代码块应该被当作一个模板。接下来是尖括号部分(< >
),它定义了模板使用的任何类型。这个例子定义了一个模板类型,用字符T. T
表示,然后用来指定返回类型和传递给函数的两个参数的类型。
注意将参数作为
const
引用传递给模板函数是个好主意。最初的Add
实现通过值传递int
类型,但是不能保证模板不会被在通过值传递时会造成性能损失的类型使用,比如复制的对象。
现在你已经模板化了Add
函数,你可以在清单 9-3 中看到main
函数中的调用代码与清单 9-1 中显示的代码没有什么不同。
清单 9-3 。 调用模板Add
功能
#include <iostream>
using namespace std;
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
int main(int argc, char* argv[])
{
const int number1{ 1 };
const int number2{ 2 };
const int result{ Add(number1, number2) };
cout << "The result of adding" << endl;
cout << number1 << endl;
cout << "to" << endl;
cout << number2 << endl;
cout << "is" << endl;
cout << result;
return 0;
}
清单 9-3 包含了一个对Add
函数的调用,其位置与清单 9-1 中的代码完全相同。这是可能的,因为编译器可以隐式地计算出与模板一起使用的正确类型。
显式与隐式模板专门化
有时,您希望明确模板可以使用的类型。清单 9-4 显示了一个显式模板专门化的例子。
清单 9-4 。 显性和隐性模板特殊化
#include <iostream>
using namespace std;
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
template <typename T>
void Print(const T& value1, const T& value2, const T& result)
{
cout << "The result of adding" << endl;
cout << value1 << endl;
cout << "to" << endl;
cout << value2 << endl;
cout << "is" << endl;
cout << result;
cout << endl << endl;
}
int main(int argc, char* argv[])
{
const int number1{ 1 };
const int number2{ 2 };
const int intResult{ Add(number1, number2) };
Print(number1, number2, intResult);
const float floatResult{ Add(static_cast<float>(number1), static_cast<float>(number2)) };
Print<float>(number1, number2, floatResult);
return 0;
}
清单 9-4 添加了一个带三个模板化参数的模板Print
函数。该函数在main
函数中被调用两次。第一次是隐式推导模板类型。这是可能的,因为传递给函数的三个参数都是类型int
;因此,编译器认为您打算调用模板的一个int
版本。对Print
的第二个调用是显而易见的。这是通过在函数名后面添加包含要使用的类型的尖括号(在本例中是float
)来实现的。由于传递给函数的变量类型不同,这是必要的。这里number1
和number2
都是int
类型,但是floatResult
是float
类型;因此,编译器无法推断出模板使用的正确类型。当我尝试使用隐式专用化编译此代码时,Visual Studio 生成了以下错误:
error C2782: 'void Print(const T &,const T &,const T &)' : template parameter 'T' is ambiguous
9-2.部分专门化模板
问题
你有一个不能用特定类型编译的模板函数。
解决办法
您可以使用部分模板专门化来创建模板重载。
它是如何工作的
模板函数体包含需要隐式属性的代码,这些隐式属性来自用于专门化该模板的类型。考虑清单 9-5 中的代码。
清单 9-5 。 模板功能
#include <iostream>
using namespace std;
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
template <typename T>
void Print(const T& value1, const T& value2, const T& result)
{
cout << "The result of adding" << endl;
cout << value1 << endl;
cout << "to" << endl;
cout << value2 << endl;
cout << "is" << endl;
cout << result;
cout << endl << endl;
}
int main(int argc, char* argv[])
{
const int number1{ 1 };
const int number2{ 2 };
const int intResult{ Add(number1, number2) };
Print(number1, number2, intResult);
return 0;
}
这段代码需要来自Add
函数和Print
函数使用的类型的两个隐式属性。Add
功能要求使用的类型也可以与+
操作符一起使用。Print
函数要求使用的类型可以传递给<<
操作符。main
函数使用这些带有int
变量的函数,因此这两个条件都满足。如果您要对自己创建的类使用Add
或Print
,那么编译器很可能无法使用带有+
或<<
操作符的类。
注意这种情况下“合适的”解决方案是添加重载的
+
和<<
操作符,这样原始代码就能按预期工作。这个例子展示了如何使用部分专门化来达到同样的结果。
你可以很容易地更新清单 9-5 中的来使用一个简单的类,如清单 9-6 中的所示。
清单 9-6 。 使用带类的模板
#include <iostream>
using namespace std;
class MyClass
{
private:
int m_Value{ 0 };
public:
MyClass() = default;
MyClass(int value)
: m_Value{ value }
{
}
MyClass(int number1, int number2)
: m_Value{ number1 + number2 }
{
}
int GetValue() const
{
return m_Value;
}
};
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
template <typename T>
void Print(const T& value1, const T& value2, const T& result)
{
cout << "The result of adding" << endl;
cout << value1 << endl;
cout << "to" << endl;
cout << value2 << endl;
cout << "is" << endl;
cout << result;
cout << endl << endl;
}
int main(int argc, char* argv[])
{
const MyClass number1{ 1 };
const MyClass number2{ 2 };
const MyClass intResult{ Add(number1, number2) };
Print(number1, number2, intResult);
return 0;
}
清单 9-6 中的代码无法编译。你的编译器将找不到合适的操作符来为+
和<<
使用MyClass
类型。你可以通过使用部分模板专门化来解决这个问题,如清单 9-7 所示。
清单 9-7 。 使用分部分项模板特殊化
#include <iostream>
using namespace std;
class MyClass
{
private:
int m_Value{ 0 };
public:
MyClass() = default;
MyClass(int value)
: m_Value{ value }
{
}
MyClass(int number1, int number2)
: m_Value{ number1 + number2 }
{
}
int GetValue() const
{
return m_Value;
}
};
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
template <>
MyClass Add(const MyClass& myClass1, const MyClass& myClass2)
{
return MyClass(myClass1.GetValue(), myClass2.GetValue());
}
template <typename T>
void Print(const T& value1, const T& value2, const T& result)
{
cout << "The result of adding" << endl;
cout << value1 << endl;
cout << "to" << endl;
cout << value2 << endl;
cout << "is" << endl;
cout << result;
cout << endl << endl;
}
template <>
void Print(const MyClass& value1, const MyClass& value2, const MyClass& result)
{
cout << "The result of adding" << endl;
cout << value1.GetValue() << endl;
cout << "to" << endl;
cout << value2.GetValue() << endl;
cout << "is" << endl;
cout << result.GetValue();
cout << endl << endl;
}
int main(int argc, char* argv[])
{
const MyClass number1{ 1 };
const MyClass number2{ 2 };
const MyClass intResult{ Add(number1, number2) };
Print(number1, number2, intResult);
return 0;
}
清单 9-7 中的代码增加了Add
和Print
的特殊版本。它通过在函数签名中使用一个空的模板类型说明符和具体的MyClass
类型来实现。您可以在Add
函数中看到这一点,这里传递的参数属于MyClass
类型,返回值属于MyClass
类型。部分专门化的Print
函数也将const
引用传递给MyClass
变量。模板函数仍然可以和变量一起使用,比如int
s 和float
s,但是现在也明确支持MyClass
类型。
为了完整起见,清单 9-8 显示了一个优选的实现,它增加了对+
和<<
操作符和MyClass
的支持。
清单 9-8 。 增加+
和<<
操作员支持到MyClass
#include <iostream>
using namespace std;
class MyClass
{
friend ostream& operator <<(ostream& os, const MyClass& myClass);
private:
int m_Value{ 0 };
public:
MyClass() = default;
MyClass(int value)
: m_Value{ value }
{
}
MyClass(int number1, int number2)
: m_Value{ number1 + number2 }
{
}
MyClass operator +(const MyClass& other) const
{
return m_Value + other.m_Value;
}
};
ostream& operator <<(ostream& os, const MyClass& myClass)
{
os << myClass.m_Value;
return os;
}
template <typename T>
T Add(const T& a, const T& b)
{
return a + b;
}
template <typename T>
void Print(const T& value1, const T& value2, const T& result)
{
cout << "The result of adding" << endl;
cout << value1 << endl;
cout << "to" << endl;
cout << value2 << endl;
cout << "is" << endl;
cout << result;
cout << endl << endl;
}
int main(int argc, char* argv[])
{
const MyClass number1{ 1 };
const MyClass number2{ 2 };
const MyClass intResult{ Add(number1, number2) };
Print(number1, number2, intResult);
return 0;
}
这段代码直接为MyClass
添加了对+
操作符的支持。还为与ostream
类型一起工作的<<
操作符指定了一个功能。这是因为cout
与ostream
(代表输出流)兼容。该函数签名作为MyClass
的friend
添加,以便函数可以从MyClass
访问内部数据。您也可以保留GetValue
访问器,而不添加操作符作为friend
函数。
9-3.创建课程模板
问题
您希望创建一个可以存储不同类型变量的类,而无需复制所有代码。
解决办法
C++ 允许创建支持抽象类型的模板类。
它是如何工作的
您可以使用template
说明符将class
定义为模板。template
说明符将类型和值作为编译器用来构建模板代码专门化的参数。清单 9-9 展示了一个使用抽象类型和值来构建模板类的例子。
清单 9-9 。 创建模板类
#include <iostream>
using namespace std;
template <typename T, int numberOfElements>
class MyArray
{
private:
T m_Array[numberOfElements];
public:
MyArray()
: m_Array{}
{
}
T& operator[](const unsigned int index)
{
return m_Array[index];
}
};
int main(int argc, char* argv[])
{
const unsigned int ARRAY_SIZE{ 5 };
MyArray<int, ARRAY_SIZE> myIntArray;
for (unsigned int i{ 0 }; i < ARRAY_SIZE; ++i)
{
myIntArray[i] = i;
}
for (unsigned int i{ 0 }; i < ARRAY_SIZE; ++i)
{
cout << myIntArray[i] << endl;
}
cout << endl;
MyArray<float, ARRAY_SIZE> myFloatArray;
for (unsigned int i{ 0 }; i < ARRAY_SIZE; ++i)
{
myFloatArray[i] = static_cast<float>(i)+0.5f;
}
for (unsigned int i{ 0 }; i < ARRAY_SIZE; ++i)
{
cout << myFloatArray[i] << endl;
}
return 0;
}
class MyArray
创建一个类型为T
的 C 风格数组和一些元素。这两者在编写类时是抽象的,在代码中使用它们时是指定的。您现在可以使用MyArray
类来创建一个任意大小的数组,其中包含任意数量的元素,这些元素可以用一个int
来表示。你可以在main
函数中看到这一点,其中MyArray class
模板专门创建了一个int
的数组和一个float
的数组。图 9-1 显示了运行这段代码时生成的输出:这两个数组包含不同类型的变量。
图 9-1 。运行清单 9-9 中的代码生成的输出
注意数组模板包装器的创建是一个简单的例子,展示了 STL 提供的
std::array
模板的基础。STL 版本支持 STL 迭代器和算法,是比自己编写实现更好的选择。
9-4.创建单件
问题
您有一个系统,您想创建一个可以从应用程序的许多地方访问的实例。
解决办法
你可以使用模板创建一个Singleton
基类 。
它是如何工作的
singleton 的基础是一个类模板。Singleton
类模板包含一个指向抽象类型的static
指针,可以用来表示你喜欢的任何类型的类。使用static
指针的副产品是可以从程序的任何地方访问类的实例。您应该小心不要滥用它,尽管它可能是一个有用的属性。清单 9-10 展示了如何创建和使用Singleton
模板。
清单 9-10 。Singleton
模板
#include <cassert>
#include <iostream>
using namespace std;
template <typename T>
class Singleton
{
private:
static T* m_Instance;
public:
Singleton()
{
assert(m_Instance == nullptr);
m_Instance = static_cast<T*>(this);
}
virtual ~Singleton()
{
m_Instance = nullptr;
}
static T& GetSingleton()
{
return *m_Instance;
}
static T* GetSingletonPtr()
{
return m_Instance;
}
};
template <typename T>
T* Singleton<T>::m_Instance = nullptr;
class Manager
: public Singleton < Manager >
{
public:
void Print() const
{
cout << "Singleton Manager Successfully Printing!";
}
};
int main(int argc, char* argv[])
{
new Manager();
Manager& manager{ Manager::GetSingleton() };
manager.Print();
delete Manager::GetSingletonPtr();
return 0;
}
清单 9-10 中的Singleton
类是一个模板类,它包含一个指向抽象类型 t 的私有静态指针。Singleton
构造函数将this
的造型赋给m_Instance
变量。以这种方式使用static_cast
是可能的,因为您知道对象的类型将是提供给模板的类型。该类的虚拟析构函数负责将m_Instance
设置回nullptr
;还有对实例的引用和指针访问器。
清单 9-10 然后使用这个模板创建一个支持Singleton
的Manager
类。它通过创建一个继承自Singleton
的类并将其自身类型传递给Singleton
模板参数来实现这一点。
注意将一个类的类型传递到该类派生自的模板中被称为奇怪的递归模板模式。
main
函数使用new
关键字创建一个Manager
。Manager
不是作为类的引用或指针存储的。虽然您可以这样做,但是从这一点来看,最好简单地使用Singleton
的访问器。您可以通过使用带有派生类名称的静态函数语法来实现这一点。main
函数通过调用Manager::GetSingleton
函数创建对Manager
实例的引用。
通过对由Manager::GetSingletonPtr
返回的值调用delete
来删除单例实例。这会导致调用~Singleton
,这将清除存储在m_Instance
中的地址,并释放用于存储实例的内存。
注这个
Singleton
类是基于 Scott Bilas 在游戏编程宝石 (Charles River Media,2000)中最初写的实现。
9-5.编译时计算值
问题
您需要计算复杂的值,并且希望避免在运行时计算它们。
解决办法
模板元编程利用 C++ 模板编译器在编译时计算值,并为用户节省运行时性能。
它是如何工作的
模板元编程可能是一个很难理解的话题。这种复杂性来自 C++ 模板编译器的能力范围。除了让您通过从函数和类中抽象类型来执行泛型编程之外,模板编译器还可以计算值。
散列数据是比较两组数据是否相等的常用方法。它的工作原理是在创建时创建数据的散列,并将散列与数据的运行时版本进行比较。您可以使用此方法在程序执行时检测数据文件的可执行文件中的更改。SDBM 散列是一个易于实现的散列函数;清单 9-11 显示了 SDBM 散列算法 的一个普通函数实现。
清单 9-11 。SDBM 哈希算法
#include <iostream>
#include <string>
using namespace std;
unsigned int SDBMHash(const std::string& key)
{
unsigned int result{ 0 };
for (unsigned int character : key)
{
result = character + (result << 6) + (result << 16) - result;
}
return result;
}
int main(int argc, char* argv[])
{
std::string data{ "Bruce Sutherland" };
unsigned int sdbmHash{ SDBMHash(data) };
cout << "The hash of " << data << " is " << sdbmHash;
return 0;
}
清单 9-11 中的SDBMHash
函数的工作方式是迭代提供的数据,并通过将数据集中的每个字节处理成一个result
变量来计算结果。这个功能版本的SDBMHash
对于创建运行时加载的数据的散列是有用的,但是这里提供的数据在编译时是已知的。通过用模板元程序替换这个函数,可以优化程序的执行速度。清单 9-12 就是这么做的。
清单 9-12 。 用模板元程序替换SDBMHash
#include <iostream>
using namespace std;
template <int stringLength>
struct SDBMCalculator
{
constexpr static unsigned int Calculate(const char* const stringToHash, unsigned int& value)
{
unsigned int character{
SDBMCalculator<stringLength - 1>::Calculate(stringToHash, value)
};
value = character + (value << 6) + (value << 16) - value;
return stringToHash[stringLength - 1];
}
constexpr static unsigned int CalculateValue(const char* const stringToHash)
{
unsigned int value{};
unsigned int character{ SDBMCalculator<stringLength>::Calculate(stringToHash, value) };
value = character + (value << 6) + (value << 16) - value;
return value;
}
};
template<>
struct SDBMCalculator < 1 >
{
constexpr static unsigned int Calculate(const char* const stringToHash, unsigned int& value)
{
return stringToHash[0];
}
};
constexpr unsigned int sdbmHash{ SDBMCalculator<16>::CalculateValue("Bruce Sutherland") };
int main(int argc, char* argv[])
{
cout << "The hash of Bruce Sutherland is " << sdbmHash << endl;
return 0;
}
您可以立即看到清单 9-12 中的代码看起来比清单 9-11 中的代码复杂得多。编写模板元程序所需的语法不是最容易读懂的。main
函数现在是单行代码。哈希值存储在一个常量中,不调用任何模板函数。您可以通过在模板函数中放置断点并运行程序的发布版本来测试这一点。
清单 9-12 中的模板元程序通过使用递归来工作。要散列的数据的长度被提供给模板参数,并且可以在初始化sdbmHash
变量时看到。这里,16
传递给模板,就是字符串“Bruce Sutherland ”的长度。模板编译器认识到它已经被提供了可以在编译时评估的数据,因此它自动调用CalculateValue
函数中的Calculate
元程序函数。这种递归一直发生,直到碰到终止符。终止符是Calculate
的部分专门化版本,一旦要散列的数据长度为 1,就会被调用。当到达终止符时,递归调用开始展开,编译器最终将模板元程序的结果存储在sdbmHash
变量中。您可以使用调试版本看到模板元程序的运行。编译器不会在调试版本中优化模板元程序,调试版本允许您测试代码并单步执行以查看结果。图 9-2 显示了运行清单 9-12 中代码的输出。
图 9-2 。由清单 9-12 中的代码生成的输出,显示了字符串“Bruce Sutherland”的 SDBM 散列
十、内存
在现代计算机中,内存是一种非常重要的资源。你的程序所操作的所有数据都会在某个时候存储到 ram 中,供处理器在以后需要完成你的部分算法时检索。
因此,对于 C++ 程序员来说,理解程序如何以及何时使用不同类型的内存是至关重要的。本章介绍了三种不同的内存空间,如何利用它们,以及每种空间对程序性能的潜在影响。
10-1.使用静态内存
问题
您有一个希望能够在代码中的任何地方访问的对象。
解决办法
静态内存可以被认为是全局变量。程序的任何部分都可以随时访问这些变量及其值。
它是如何工作的
您使用的编译器会自动为您创建的任何全局变量在静态内存空间中添加内存。静态变量的地址通常可以在可执行文件的地址空间中找到,因此可以被程序的任何部分随时访问。清单 10-1 显示了一个无符号整数全局变量的例子。
清单 10-1 。一个全局变量
#include <iostream>
using namespace std;
unsigned int counter{ 0 };
void IncreaseCounter()
{
counter += 10;
cout << "counter is " << counter << endl;
}
int main(int argc, char* argv[])
{
counter += 5;
cout << "counter is " << counter << endl;
IncreaseCounter();
return 0;
}
清单 10-1 中的变量counter
是用全局范围声明的。结果是可以在程序中全局访问该变量。您可以在main
函数和IncreaseCounter
函数中看到这一点。这两个函数都增加了同一个全局counter
变量的值。图 10-1 所示的结果证实了这一点。
图 10-1 。显示更改全局变量的结果的输出
全局变量在某些情况下可能是有用的,但在其他情况下可能会导致许多问题。配方 9-4 展示了使用静态类成员变量来创建一个Singleton
对象。静态成员也是一种全局变量,因此可以从程序中的任何地方访问。静态变量的一个普遍问题是它们的创建顺序。C++ 标准不保证静态变量会以给定的顺序初始化。这可能导致使用许多依赖全局变量的程序遇到问题,并由于意外的初始化顺序而崩溃。全局变量还会导致多线程编程中的许多问题,因为多个线程可以同时访问静态地址空间,并产生意想不到的结果。通常建议您将全局变量的使用保持在最低限度。
10-2.使用堆栈内存
问题
您需要内存来存储临时变量,以便在函数中工作。
解决办法
C++ 程序可以使用一个增长和收缩的堆栈来为局部变量提供临时空间。
它是如何工作的
因为 C++ 程序中的所有变量都需要内存支持,所以会为函数中定义的变量动态创建临时空间。这是使用堆栈实现的。当调用一个函数时,编译器会添加机器码,分配足够的堆栈空间来存储函数所需的所有变量。
使用两个名为esp
的寄存器(在基于 x86 的 CPU 上)来操作堆栈,ebp. esp
是堆栈指针,ebp
是基址指针。基址指针用于存储前一个堆栈帧的地址。这允许当前函数在执行结束时返回到正确的堆栈。esp
寄存器用于存储堆栈的当前顶部;这允许在当前函数调用另一个函数时更新ebp
。
在程序栈上为局部变量创建足够空间的过程如清单 10-2 所示。
清单 10-2 。显示创建 20 字节堆栈帧的 x86 程序集
push ebp
mov ebp, esp
sub esp 20
清单 10-2 中的三行 x86 汇编语言展示了在 x86 中创建堆栈框架的基础。首先,push
指令用于将当前基址指针移动到堆栈上。push
指令将esp
向下移动足够远,以存储ebp
的值,然后将该值移动到堆栈上。然后将esp
的当前值移入ebp
,将基址指针向上移动到当前堆栈帧的开头。最后一条指令从esp
中减去堆栈帧的大小。由此可以清楚地看出,基于 x86 的计算机中的堆栈向下增长到 0。
然后,程序使用从基指针的偏移量来访问堆栈中的每个变量。在图 10-2 所示的 Visual Studio 反汇编中可以看到这三行。
图 10-2 。从 x86 程序反汇编,显示堆栈框架的创建
清单 10-3 显示了图 10-2 中的拆卸的代码。
清单 10-3 。用于查看反汇编的简单程序
#include <iostream>
using namespace std;
void Function()
{
int a{ 0 };
cout << a;
}
int main(int argc, char* argv[])
{
Function();
return 0;
}
您创建的所有局部变量都分配在堆栈上。类变量的构造函数在它们被创建时被调用,它们的析构函数在栈被销毁时被调用。清单 10-4 展示了一个简单的程序,它由一个class
和一个构造函数和一个析构函数组成。
清单 10-4 。堆栈上的类变量
#include <iostream>
using namespace std;
class MyClass
{
public:
MyClass()
{
cout << "Constructor called!" << endl;
}
~MyClass()
{
cout << "Destructor called!" << endl;
}
};
int main(int argc, char* argv[])
{
MyClass myClass;
cout << "Function body!" << endl;
return 0;
}
清单 10-4 中变量myClass
的构造函数在初始化时被调用。执行函数体的其余部分,当变量超出范围时,调用class
析构函数。myClass
变量在return
语句后超出范围。发生这种情况是因为可能需要函数中的局部变量来计算函数返回值。你可以在图 10-3 中看到清单 10-4 的输出。
图 10-3 。运行清单 10-4 中代码的输出
清单 10-4 中的代码展示了函数中class
变量的创建和销毁。在 C++ 中也可以控制堆栈框架的创建。您可以使用花括号在现有范围内创建一个新的范围。清单 10-5 创建了几个不同的作用域,每个作用域都有自己的局部变量。
清单 10-5 。创建多个范围
#include <iostream>
using namespace std;
class MyClass
{
private:
static int m_Count;
int m_Instance{ -1 };
public:
MyClass()
: m_Instance{m_Count++}
{
cout << "Constructor called on " << m_Instance << endl;
}
~MyClass()
{
cout << "Destructor called on " << m_Instance << endl;
}
};
int MyClass::m_Count{ 0 };
int main(int argc, char* argv[])
{
MyClass myClass1;
{
MyClass myClass2;
{
MyClass myClass3;
}
}
return 0;
}
清单 10-5 中的代码展示了在一个函数中使用花括号创建多个堆栈框架。类MyClass
包含一个static
变量m_Count
,用于跟踪不同的实例。每次创建一个新实例时,这个变量都会后递增,前递增的值存储在m_Instance
中。每次关闭作用域时,都会对局部变量调用析构函数。结果如图 10-4 所示。
图 10-4 。显示具有多个范围的对象的销毁顺序的输出
10-3.使用堆内存
问题
您需要创建一个比单个本地作用域更长的大型内存池。
解决办法
C++ 提供了new
和delete
操作符,允许您管理大型动态分配内存池。
它是如何工作的
动态分配内存对于许多长时间运行的程序来说非常重要。对于允许用户生成自己的内容或从文件中加载资源的程序来说,这是必不可少的。如果不使用动态分配的内存,通常很难(如果不是不可能的话)为用于流式视频或社交媒体内容的程序(如 web 浏览器)提供足够的内存,因为您无法在创建程序时确定内存需求。
您可以使用 C++ new
和delete
操作符在一个通常称为堆的地址空间中分配动态内存。new
操作符返回一个指针,指向动态分配的内存,该内存足够大,可以存储正在创建的变量类型。清单 10-6 展示了如何使用new
和delete
操作符。
清单 10-6 。使用new
和delete
#include <iostream>
using namespace std;
int main(int argc, char* argv[])
{
int* pInt{ new int };
*pInt = 100;
cout << hex << "The address at pInt is " << pInt << endl;
cout << dec << "The value at pInt is " << *pInt << endl;
delete pInt;
pInt = nullptr;
return 0;
}
这段代码使用new
操作符分配足够的内存来存储单个int
变量。指针从new
返回并存储在变量pInt
中。返回的内存是未初始化的,通常在创建时初始化内存是个好主意。你可以在main
中看到这一点,这里使用指针解引用操作符将pInt
指向的内存初始化为 100。
一旦从堆中分配了内存,您就有责任确保它被正确地返回给操作系统。否则会导致内存泄漏。内存泄漏会给用户带来问题,通常会导致计算机性能下降、内存碎片,在严重的情况下,还会导致计算机因内存不足而崩溃。
使用delete
操作符将堆内存返回给操作系统。这个操作符告诉系统,您不再需要从最初调用new
返回的所有内存。在调用了delete
之后,你的程序不应该再试图使用new
返回的内存。这样做会导致未定义的行为,这通常会导致程序崩溃。由于访问被释放的内存而导致的崩溃通常很难发现,因为它们出现在你无法以任何方式链接到违规代码的地方。通过将任何指向内存的指针设置为nullptr
,可以确保你的程序不会访问被删除的内存。
清单 10-6 的输出如图图 10-5 所示。
图 10-5 。来自清单 10-6 的输出显示了动态分配内存中存储的地址和值
清单 10-6 中的new
和delete
操作符用于分配单个对象。还有new
和delete
数组操作符,用于分配同一个对象的倍数。清单 10-7 显示了数组new
和delete
操作符的作用。
清单 10-7 。数组new
和delete
运算符
#include <iostream>
using namespace std;
class MyClass
{
private:
int m_Number{ 0 };
public:
MyClass() = default;
~MyClass()
{
cout << "Destroying " << m_Number << endl;
}
void operator=(const int value)
{
m_Number = value;
}
};
int main(int argc, char* argv[])
{
const unsigned int NUM_ELEMENTS{ 5 };
MyClass* pObjects{ new MyClass[NUM_ELEMENTS] };
pObjects[0] = 100;
pObjects[1] = 45;
pObjects[2] = 31;
pObjects[3] = 90;
pObjects[4] = 58;
delete[] pObjects;
pObjects = nullptr;
return 0;
}
清单 10-7 中的代码创建了一个对象数组。MyClass
类由一个重载的赋值操作符和一个析构函数组成,前者初始化创建的对象,后者显示数组中元素的销毁顺序。在对象数组上使用标准的delete
操作符会给你的程序带来各种问题,因为标准的delete
操作符只在数组的第一个元素上调用类析构函数。如果您的类分配了自己的内存,那么数组中的每个后续对象都会泄漏它们的内存。使用delete
数组操作符可以确保数组中的每个析构函数都被调用。你可以看到数组中元素的每个析构函数都在图 10-6 中被调用。
图 10-6 。使用数组运算符delete
时,输出显示每个析构函数都已被调用
10-4.使用自动共享内存
问题
您有一个可以由多个具有不同生命周期的系统共享的对象。
解决办法
C++ 提供了shared_ptr
模板,可以在不再需要内存时自动删除它。
它是如何工作的
C++ 中动态分配的内存必须由程序员删除。这意味着你有责任确保你的程序在任何时候都像用户期望的那样运行。C++ 提供了shared_ptr
模板,它跟踪你的程序中有多少地方共享对同一个内存的访问,并且可以在不再需要这个内存时删除它。清单 10-8 展示了如何创建一个共享指针。
清单 10-8 。创建共享指针
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
int m_Number{ 0 };
public:
MyClass(int value)
: m_Number{ value }
{
}
~MyClass()
{
cout << "Destroying " << m_Number << endl;
}
void operator=(const int value)
{
m_Number = value;
}
int GetNumber() const
{
return m_Number;
}
};
using SharedMyClass = shared_ptr< MyClass >;
int main(int argc, char* argv[])
{
SharedMyClass sharedMyClass{ new MyClass(10) };
return 0;
}
这段代码包含一个类MyClass
,它有一个私有的整数成员变量。还有一个类型别名用来表示一个shared_ptr
到一个MyClass
对象。从长远来看,这种类型别名用于使编写代码变得更容易和更易维护。shared_ptr
模板本身接受一个对象类型的参数,您希望在您的程序中共享这个对象。在这种情况下,您想要共享类型为MyClass
的动态对象。
在main
函数的第一行创建了一个SharedMyClass
的实例。这个实例用一个动态分配的MyClass
对象初始化。MyClass
对象本身用值 10 初始化。在main
的主体中唯一的其他代码是return
语句。尽管如此,图 10-7 显示MyClass
的析构函数已经在sharedMyClass
中存储的对象上被调用。
图 10-7 。输出显示MyClass
析构函数已经在清单 10-8 中被调用
一旦shared_ptr
的最后一个实例超出范围,shared_ptr
模板会自动调用它所包装的内存中的delete
。在这种情况下,main
函数中只有一个shared_ptr
;因此MyClass
对象被删除,它的析构函数在函数返回语句执行后被调用。
清单 10-9 展示了如何使用shared_ptr
将共享内存的所有权从一个函数转移到另一个函数,并且仍然保持这个自动清理代码。
清单 10-9 。在函数间转移动态内存
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
int m_Number{ 0 };
public:
MyClass(int value)
: m_Number{ value }
{
}
~MyClass()
{
cout << "Destroying " << m_Number << endl;
}
void operator=(const int value)
{
m_Number = value;
}
int GetNumber() const
{
return m_Number;
}
};
using SharedMyClass = shared_ptr< MyClass >;
void ChangeSharedValue(SharedMyClass sharedMyClass)
{
if (sharedMyClass != nullptr)
{
*sharedMyClass = 100;
}
}
int main(int argc, char* argv[])
{
SharedMyClass sharedMyClass{ new MyClass(10) };
ChangeSharedValue(sharedMyClass);
return 0;
}
清单 10-9 创建一个SharedMyClass
实例,指向一个用值 10 初始化的MyClass
对象。然后,sharedMyClass
实例通过值传递给ChangeSharedValue
函数。通过值传递一个shared_ptr
来复制指针。现在您有了两个SharedMyClass
模板的实例,它们都指向同一个MyClass
实例。直到的两个shared_ptr
实例都超出范围,才会调用MyClass
的析构函数。图 10-8 显示了MyClass
实例的初始值被改变了,并且析构函数只被调用了一次。
图 10-8 。显示共享对象的存储值被更改和销毁一次的输出
10-5.创建单实例动态对象
问题
您有一个想要传递的对象,但是您只希望有该对象的一个实例。
解决办法
C++ 提供了unique_ptr
模板,允许一个指针实例被转移但不能共享。
它是如何工作的
unique_ptr
是一个模板,可以用来存储动态分配内存的指针。它与shared_ptr
的不同之处在于,一次只能有一个对动态内存的引用。清单 10-10 展示了如何创建一个unique_ptr
。
清单 10-10 。创建一个unique_ptr
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
int m_Number{ 0 };
public:
MyClass(int value)
: m_Number{ value }
{
}
~MyClass()
{
cout << "Destroying " << m_Number << endl;
}
void operator=(const int value)
{
m_Number = value;
}
int GetNumber() const
{
return m_Number;
}
};
using UniqueMyClass = unique_ptr< MyClass >;
void CreateUniqueObject()
{
UniqueMyClass uniqueMyClass{ make_unique<MyClass>(10) };
}
int main(int argc, char* argv[])
{
cout << "Begin Main!" << endl;
CreateUniqueObject();
cout << "Back in Main!" << endl;
return 0;
}
清单 10-10 中的unique_ptr
是在一个函数中创建的,用来演示当unique_ptr
超出作用域时,动态创建的对象的实例被销毁。你可以在图 10-9 的输出中看到这一点。
图 10-9 。显示存储在unique_ptr
中的动态分配对象的销毁的输出
清单 10-10 展示了unique_ptr
可以用来在不再需要时自动删除动态分配的内存。它没有显示出unique_ptr
可以用来在不同的作用域之间转移单个对象的所有权。这显示在清单 10-11 中。
清单 10-11 。在unique_ptr
实例之间转移动态分配的内存
#include <iostream>
#include <memory>
using namespace std;
class MyClass
{
private:
int m_Number{ 0 };
public:
MyClass(int value)
: m_Number{ value }
{
}
~MyClass()
{
cout << "Destroying " << m_Number << endl;
}
void operator=(const int value)
{
m_Number = value;
}
int GetNumber() const
{
return m_Number;
}
};
using UniqueMyClass = unique_ptr< MyClass >;
void CreateUniqueObject(UniqueMyClass& referenceToUniquePtr)
{
UniqueMyClass uniqueMyClass{ make_unique<MyClass>(10) };
cout << hex << showbase;
cout << "Address in uniqueMyClass " << uniqueMyClass.get() << endl;
referenceToUniquePtr.swap(uniqueMyClass);
cout << "Address in uniqueMyClass " << uniqueMyClass.get() << endl;
}
int main(int argc, char* argv[])
{
cout << "Begin Main!" << endl;
UniqueMyClass uniqueMyClass;
CreateUniqueObject(uniqueMyClass);
cout << "Address in main's uniqueMyClass " << uniqueMyClass.get() << endl;
cout << dec << noshowbase << "Back in Main!" << endl;
return 0;
}
清单 10-11 中的代码在CreateUniqueObject
函数中创建了一个MyClass
的实例。这个函数还引用了另一个 un qiue_ptr<MyClass>
,这个 un【】用于将动态分配的对象转移到函数之外。使用由联合国ique_ptr
模板提供的swap
函数 来实现传输。当所有的UniqueMyClass
实例都超出范围时,在main
函数的末尾调用MyClass
析构函数。你可以在图 10-10 中看到MyClass
实例的内存转移和销毁顺序。
图 10-10 。输出显示了一个unique_ptr
的传输和它的动态分配内存的销毁
10-6.创建智能指针
问题
您希望在不支持shared_ptr
和unique_ptr
的系统上使用自动化指针管理。
解决办法
您可以在class
中使用成员变量来跟踪当前有多少对数据的引用在使用中。
它是如何工作的
在 C++11 中,unique_ptr
和shared_ptr
模板被添加到 STL 中。有些程序是在无法访问 C++11 或者无法访问 STL 的情况下编写的。在这种情况下,您可以编写自己的智能指针实现。首先,您需要创建一个可用于引用计数的对象。引用计数的工作原理是,每当您复制一个想要计数的对象时,就增加一个整数。清单 10-12 显示了一个引用计数类的代码。
清单 10-12 。引用计数类的代码
class ReferenceCount
{
private:
int m_Count{ 0 };
public:
void Increment()
{
++m_Count;
}
int Decrement()
{
return --m_Count;
}
int GetCount() const
{
return m_Count;
}
};
这个类非常基础。它只包含一个跟踪计数的成员变量以及增加和减少计数的方法。GetCount
方法提供对计数的访问,允许在调试期间打印。
然后在名为SmartPointer
的模板类中使用ReferenceCount
类。该类提供了一个模板参数,您可以使用该参数将模板专门化为您希望自动跟踪的对象类型。该类有一个成员变量,一个指向被跟踪对象的指针,另一个指向ReferenceCount
对象。通过一个指针来访问ReferenceCount
对象,这样它就可以在访问同一个动态分配对象的多个SmartPointer
对象之间共享。你可以在清单 10-13 中看到SmartPointer
和的代码。
清单 10-13 。SmartPointer
类
template <typename T>
class SmartPointer
{
private:
T* m_Object{ nullptr };
ReferenceCount* m_ReferenceCount{ nullptr };
public:
SmartPointer()
{
}
SmartPointer(T* object)
: m_Object{ object }
, m_ReferenceCount{ new ReferenceCount }
{
m_ReferenceCount->Increment();
cout << "Created smart pointer! Reference count is "
<< m_ReferenceCount->GetCount() << endl;
}
virtual ~SmartPointer()
{
if (m_ReferenceCount)
{
int decrementedCount = m_ReferenceCount->Decrement();
cout << "Destroyed smart pointer! Reference count is "
<< decrementedCount << endl;
if (decrementedCount == 0)
{
delete m_ReferenceCount;
delete m_Object;
}
m_ReferenceCount = nullptr;
m_Object = nullptr;
}
}
SmartPointer(const SmartPointer<T>& other)
: m_Object{ other.m_Object }
, m_ReferenceCount{ other.m_ReferenceCount }
{
m_ReferenceCount->Increment();
cout << "Copied smart pointer! Reference count is "
<< m_ReferenceCount->GetCount() << endl;
}
SmartPointer<T>& operator=(const SmartPointer<T>& other)
{
if (this != &other)
{
if (m_ReferenceCount && m_ReferenceCount->Decrement() == 0)
{
delete m_ReferenceCount;
delete m_Object;
}
m_Object = other.m_Object;
m_ReferenceCount = other.m_ReferenceCount;
m_ReferenceCount->Increment();
}
cout << "Assigning smart pointer! Reference count is "
<< m_ReferenceCount->GetCount() << endl;
return *this;
}
SmartPointer(SmartPointer<T>&& other)
: m_Object{ other.m_Object }
, m_ReferenceCount{ other.m_ReferenceCount }
{
other.m_Object = nullptr;
other.m_ReferenceCount = nullptr;
}
SmartPointer<T>& operator=(SmartPointer<T>&& other)
{
if (this != &other)
{
m_Object = other.m_Object;
m_ReferenceCount = other.m_ReferenceCount;
other.m_Object = nullptr;
other.m_ReferenceCount = nullptr;
}
}
T& operator*()
{
return *m_Object;
}
};
您可以在清单 10-13 的中看到用于存储动态分配对象和SmartPointer
类中的ReferenceCount
对象的成员变量。对象指针是一个指向抽象模板类型的指针;这允许任何类型的使用被SmartPointer
模板跟踪。
SmartPointer
中的第一个公共方法是构造函数。可以创建一个新的SmartPointer
作为空指针或者指向一个已经存在的对象。一个空的SmartPointer
将m_Object
和m_ReferenceCount
都设置为nullptr
。另一个构造函数取一个指向T
的指针,这个指针导致一个SmartPointer
被初始化。在这种情况下,创建一个新的ReferenceCount
对象来跟踪传递给构造函数的对象的使用。这样做的副作用是,新的SmartPointer
只能在用对象指针初始化时创建;空值SmartPointer
只能从另一个SmartPointer
对象分配。
SmartPointer
析构函数检查一个ReferenceCount
对象是否被类持有(记住它可能是空SmartPointer
中的nullptr
)。如果一个指向ReferenceCount
对象的指针被保持,它的计数就会减少。如果计数已经达到 0,那么你知道这个SmartPointer
是最后一个引用这个动态分配的对象。在这种情况下,您可以自由地删除ReferenceCount
对象和由SmartPointer
持有的对象。
SmartPointer
中的下一个方法是copy
构造器。该方法只是将传递给该方法的参数中的m_Object
和m_ReferenceCount
指针复制到被复制构造的对象中。然后,它确保引用计数递增。对Increment
的调用是必不可少的,因为现在有两个SmartPointer
对象引用同一个动态分配的对象。在这里错过对Increment
的调用会导致delete
在第一个SmartPointer
的析构函数中被调用而超出范围。
赋值操作符的工作与copy
构造函数略有不同。在copy
构造函数中,您可以自由地假设现有对象是新的,因此没有指向现有对象或ReferenceCount
实例。这在赋值操作符中是不正确的;因此,有必要解释这种情况的发生。您可以看到赋值操作符首先检查以确保操作符没有将对象赋值给它自己;在这种情况下,就没有工作可做了。如果正在分配一个新对象,则检查ReferenceCount
指针是否有效。如果是,那么就叫Decrement
;并且在返回 0 的情况下,删除现有的m_ReferenceCount
和m_Object
指针。m_Object
和m_ReferenceCount
指针总是从赋值操作符方法的参数复制到这个的变量中,并且在新的ReferenceCount
对象上调用Increment
。
接下来是一个move
构造函数和move
赋值操作符。这些都符合 C++ 的五原则。这是一个编程指南,建议在重载copy
构造函数或赋值操作符的任何情况下,都应该重载所有五个析构函数、copy
构造函数、赋值操作符、move
构造函数和move
赋值操作符。移动操作本质上是破坏性的,因此不会调用Increment
或Decrement
。这些都是不必要的,因为在这两种情况下,参数上的m_Object
和m_ReferenceCount
指针都被设置为nullptr
,这意味着delete
永远不会在它们的析构函数中被调用。支持move
构造函数和move
赋值操作符提供了一种更有效的方法将SmartPointer
对象传入和传出函数。
最后一个方法提供了对由SmartPointer
对象存储的数据的访问。如果在空的SmartPointer
对象上调用这个方法,这可能会导致崩溃。您应该注意只尝试解引用有效的SmartPointer
实例。
注意 清单 10-14 包含调试代码,允许打印对象状态,以便于说明。该代码可以从工作解决方案中删除。
清单 10-14 展示了一个正在使用的SmartPointer
类的完整工作示例。
清单 10-14 。使用SmartPointer
#include <iostream>
using namespace std;
class ReferenceCount
{
private:
int m_Count{ 0 };
public:
void Increment()
{
++m_Count;
}
int Decrement()
{
return --m_Count;
}
int GetCount() const
{
return m_Count;
}
};
template <typename T>
class SmartPointer
{
private:
T* m_Object{ nullptr };
ReferenceCount* m_ReferenceCount{ nullptr };
public:
SmartPointer()
{
}
SmartPointer(T* object)
: m_Object{ object }
, m_ReferenceCount{ new ReferenceCount }
{
m_ReferenceCount->Increment();
cout << "Created smart pointer! Reference count is " << m_ReferenceCount->GetCount() << endl;
}
virtual ~SmartPointer()
{
if (m_ReferenceCount)
{
int decrementedCount = m_ReferenceCount->Decrement();
cout << "Destroyed smart pointer! Reference count is " << decrementedCount << endl;
if (decrementedCount <= 0)
{
delete m_ReferenceCount;
delete m_Object;
}
m_ReferenceCount = nullptr;
m_Object = nullptr;
}
}
SmartPointer(const SmartPointer<T>& other)
: m_Object{ other.m_Object }
, m_ReferenceCount{ other.m_ReferenceCount }
{
m_ReferenceCount->Increment();
cout << "Copied smart pointer! Reference count is " << m_ReferenceCount->GetCount() << endl;
}
SmartPointer<T>& operator=(const SmartPointer<T>& other)
{
if (this != &other)
{
if (m_ReferenceCount && m_ReferenceCount->Decrement() == 0)
{
delete m_ReferenceCount;
delete m_Object;
}
m_Object = other.m_Object;
m_ReferenceCount = other.m_ReferenceCount;
m_ReferenceCount->Increment();
}
cout << "Assigning smart pointer! Reference count is " << m_ReferenceCount->GetCount() << endl;
return *this;
}
SmartPointer(SmartPointer<T>&& other)
: m_Object{ other.m_Object }
, m_ReferenceCount{ other.m_ReferenceCount }
{
other.m_Object = nullptr;
other.m_ReferenceCount = nullptr;
}
SmartPointer<T>& operator=(SmartPointer<T>&& other)
{
if (this != &other)
{
m_Object = other.m_Object;
m_ReferenceCount = other.m_ReferenceCount;
other.m_Object = nullptr;
other.m_ReferenceCount = nullptr;
}
}
T& operator*()
{
return *m_Object;
}
};
struct MyStruct
{
public:
int m_Value{ 0 };
~MyStruct()
{
cout << "Destroying MyStruct object!" << endl;
}
};
using SmartMyStructPointer = SmartPointer< MyStruct >;
SmartMyStructPointer PassValue(SmartMyStructPointer smartPointer)
{
SmartMyStructPointer returnValue;
returnValue = smartPointer;
return returnValue;
}
int main(int argc, char* argv[])
{
SmartMyStructPointer smartPointer{ new MyStruct };
(*smartPointer).m_Value = 10;
SmartMyStructPointer secondSmartPointer = PassValue(smartPointer);
return 0;
}
清单 10-14 显示了一个使用SmartPointer
模板在main
和PassValue
函数之间传递的MyStruct
实例。创建类型别名是为了确保MyStruct
的SmartPointer
的类型是有效的,并且始终易于维护。代码使用来自SmartPointer
模板的构造函数、copy
构造函数和赋值操作符。只有当最后一个SmartPointer
实例在main
函数结束时超出范围,才会自动删除MyStruct
对象。
图 10-11 显示了运行清单 10-14 中的代码时生成的输出。
图 10-11 。SmartPointer
行动中的一个工作实例
10-7.通过重载 new 和 delete 调试内存问题
问题
你的程序中有一些内存问题,你想在程序的分配和释放中添加诊断代码。
解决办法
C++ 允许用定制的版本替换new
和delete
操作符。
它是如何工作的
C++ new
和delete
操作符归结为函数调用。全局new
函数的签名是
void* operator new(size_t size);
全局delete
函数的签名是
void delete(void* ptr);
new
函数将待分配的字节数作为参数,delete
函数将从new
返回的内存地址作为指针。这些函数可以被替换以向程序提供额外的调试信息。清单 10-15 展示了一个给内存分配添加一个头的例子,以帮助程序调试。
清单 10-15 。向内存分配添加标头
#include <cstdlib>
#include <iostream>
using namespace std;
struct MemoryHeader
{
const char* m_Filename{ nullptr };
int m_Line{ -1 };
};
void* operator new(size_t size, const char* filename, int line) noexcept
{
void* pMemory{ malloc(size + sizeof(MemoryHeader)) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemory) };
pMemoryHeader->m_Filename = filename;
pMemoryHeader->m_Line = line;
return static_cast<void*>(static_cast<char*>(pMemory)+sizeof(MemoryHeader));
}
void operator delete(void* pMemory) noexcept
{
char* pMemoryHeaderStart{ reinterpret_cast<char*>(pMemory)-sizeof(MemoryHeader) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemoryHeaderStart) };
cout << "Deleting memory allocated from: "
<< pMemoryHeader->m_Filename << ":" << pMemoryHeader->m_Line << endl;
free(pMemoryHeader);
}
#define new new(__FILE__, __LINE__)
class MyClass
{
private:
int m_Value{ 1 };
};
int main(int argc, char* argv[])
{
int* pInt{ new int };
*pInt = 1;
delete pInt;
MyClass* pClass{ new MyClass };
delete pClass;
return 0;
}
这段代码用自定义版本替换了new
和delete
函数。new
的自定义版本不符合标准签名,所以用宏替换了标准版本。这样做是为了让编译器告诉定制的new
函数文件名和调用new
的行号。这允许您跟踪单个分配到它们在程序源代码中的确切位置。当您处理内存问题时,这是一个非常有用的调试工具。
自定义new
函数将MemoryHeader
结构的大小添加到程序请求的字节数中。然后将MemoryHeader struct
中的m_Filename
指针设置为提供给new
的filename
参数。类似地,m_Line
成员被设置为传入的line
参数。从new
返回的地址是内存用户区开始的地址,不包括MemoryHeader
结构;这允许在内存子系统级别添加和寻址调试信息,并且对程序的其余部分完全透明。
delete
功能显示了该调试信息的基本用途。它只是打印出被释放的内存块被分配的那一行。它通过从函数传递的地址中减去头的大小来获得内存头的地址。
new
宏用于给出一个简单的方法,将__FILE__
和__LINE__
宏传递给重载的new
函数。这些宏被称为内置宏,由大多数现代 C++ 编译器提供。这些宏被替换为指向文件名和使用它们的行号的指针。将它们添加到new
宏中会导致程序中每次调用new
的文件名和行号被传递给定制的new
分配器。
new
和delete
函数中使用的malloc
和free
函数是 C 风格的内存分配函数。这些用于防止与许多不同类型的 C++ 分配函数发生冲突。清单 10-15 中所示的函数适用于分配单个对象。也可以替换 C++ 数组new
和delete
函数。当您试图跟踪诸如内存泄漏之类的问题时,替换这些函数是非常必要的。清单 10-16 展示了这些函数的作用。
清单 10-16 。替换数组new
和delete
运算符
#include <cstdlib>
#include <iostream>
using namespace std;
struct MemoryHeader
{
const char* m_Filename{ nullptr };
int m_Line{ -1 };
};
void* operator new(size_t size, const char* filename, int line) noexcept
{
void* pMemory{ malloc(size + sizeof(MemoryHeader)) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemory) };
pMemoryHeader->m_Filename = filename;
pMemoryHeader->m_Line = line;
return static_cast<void*>(static_cast<char*>(pMemory)+sizeof(MemoryHeader));
}
void* operator new[](size_t size, const char* filename, int line) noexcept
{
void* pMemory{ malloc(size + sizeof(MemoryHeader)) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemory) };
pMemoryHeader->m_Filename = filename;
pMemoryHeader->m_Line = line;
return static_cast<void*>(static_cast<char*>(pMemory)+sizeof(MemoryHeader));
}
void operator delete(void* pMemory) noexcept
{
char* pMemoryHeaderStart{ reinterpret_cast<char*>(pMemory)-sizeof(MemoryHeader) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemoryHeaderStart) };
cout << "Deleting memory allocated from: "
<< pMemoryHeader->m_Filename << ":" << pMemoryHeader->m_Line << endl;
free(pMemoryHeader);
}
void operator delete[](void* pMemory) noexcept
{
char* pMemoryHeaderStart{ reinterpret_cast<char*>(pMemory)-sizeof(MemoryHeader) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemoryHeaderStart) };
cout << "Deleting memory allocated from: "
<< pMemoryHeader->m_Filename << ":" << pMemoryHeader->m_Line << endl;
free(pMemoryHeader);
}
#define new new(__FILE__, __LINE__)
class MyClass
{
private:
int m_Value{ 1 };
};
int main(int argc, char* argv[])
{
int* pInt{ new int };
*pInt = 1;
delete pInt;
MyClass* pClass{ new MyClass };
delete pClass;
const unsigned int NUM_ELEMENTS{ 5 };
int* pArray{ new int[NUM_ELEMENTS] };
delete[] pArray;
return 0;
}
数组new
和delete
操作符的签名与标准的new
和delete
操作符的区别仅在于它们的签名中出现了[]
操作符,如清单 10-16 中的所示。图 10-12 显示了该代码生成的输出。
图 10-12 。显示使用替换的new
和delete
操作符的输出
到目前为止,你在这个配方中看到的new
和delete
函数已经成为了new
和delete
操作符的全局替换。也可以替换特定职业的new
和delete
。您可以将这些函数直接添加到类定义中,并且这些函数将在创建和销毁该类型对象的动态实例时使用。清单 10-17 显示了替换全局new
、new[]
、delete
和delete[]
操作符的代码,还将new
和delete
操作符添加到了MyClass class
定义中。
清单 10-17 。将new
和delete
运算符添加到MyClass
#include <cstdlib>
#include <iostream>
using namespace std;
struct MemoryHeader
{
const char* m_Filename{ nullptr };
int m_Line{ -1 };
};
void* operator new(size_t size, const char* filename, int line) noexcept
{
void* pMemory{ malloc(size + sizeof(MemoryHeader)) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemory) };
pMemoryHeader->m_Filename = filename;
pMemoryHeader->m_Line = line;
return static_cast<void*>(static_cast<char*>(pMemory)+sizeof(MemoryHeader));
}
void* operator new[](size_t size, const char* filename, int line) noexcept
{
void* pMemory{ malloc(size + sizeof(MemoryHeader)) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemory) };
pMemoryHeader->m_Filename = filename;
pMemoryHeader->m_Line = line;
return static_cast<void*>(static_cast<char*>(pMemory)+sizeof(MemoryHeader));
}
void operator delete(void* pMemory) noexcept
{
char* pMemoryHeaderStart{ reinterpret_cast<char*>(pMemory)-sizeof(MemoryHeader) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemoryHeaderStart) };
cout << "Deleting memory allocated from: "
<< pMemoryHeader->m_Filename << ":" << pMemoryHeader->m_Line << endl;
free(pMemoryHeader);
}
void operator delete[](void* pMemory) noexcept
{
char* pMemoryHeaderStart{ reinterpret_cast<char*>(pMemory)-sizeof(MemoryHeader) };
MemoryHeader* pMemoryHeader{ reinterpret_cast<MemoryHeader*>(pMemoryHeaderStart) };
cout << "Deleting memory allocated from: "
<< pMemoryHeader->m_Filename << ":" << pMemoryHeader->m_Line << endl;
free(pMemoryHeader);
}
class MyClass
{
private:
int m_Value{ 1 };
public:
void* operator new(size_t size, const char* filename, int line) noexcept
{
cout << "Allocating memory for MyClass!" << endl;
return malloc(size);
}
void operator delete(void* pMemory) noexcept
{
cout << "Freeing memory for MyClass!" << endl;
free(pMemory);
}
};
#define new new(__FILE__, __LINE__)
int main(int argc, char* argv[])
{
int* pInt{ new int };
*pInt = 1;
delete pInt;
MyClass* pClass{ new MyClass };
delete pClass;
const unsigned int NUM_ELEMENTS{ 5 };
MyClass* pArray{ new MyClass[NUM_ELEMENTS] };
delete[] pArray;
return 0;
}
创建MyClass
的单个实例时,MyClass
定义中的new
和delete
操作符在main
函数中被调用。在图 10-13 的输出中可以看到这种情况。
图 10-13 。显示在MyClass
中使用成员new
和delete
操作符的输出
10-8.计算代码更改的性能影响
问题
您希望确定您对代码所做的更改是比现有代码快还是慢。
解决办法
C++ 提供对计算机系统高性能定时器的访问,以实现高精度计时。
它是如何工作的
C++ 编程语言提供了对高分辨率定时器的访问,该定时器允许您对代码的不同部分进行计时测量。这可以让你记录你的函数或算法所花费的时间,并在不同的版本之间进行比较,从而找出哪一个是最有效率和性能的。
清单 10-18 显示了用于计时一个循环中三个不同迭代次数的代码。
清单 10-18 。使用chrono::high_resolution_timer
#include <chrono>
#include <iostream>
using namespace std;
void RunTest(unsigned int numberIterations)
{
auto start = chrono::high_resolution_clock::now();
for (unsigned int i{ 0 }; i < numberIterations; ++i)
{
unsigned int squared{ i*i*I };
}
auto end = chrono::high_resolution_clock::now();
auto difference = end - start;
cout << "Time taken: "
<< chrono::duration_cast<chrono::microseconds>(difference).count()
<< " microseconds!" << endl;
}
int main(int argc, char* argv[])
{
RunTest(10000000);
RunTest(100000000);
RunTest(1000000000);
return 0;
}
这个清单显示了 STL 中的chrono
名称空间提供了一个名为high_resolution_clock
的struct
和一个名为now
的静态函数。这个函数从chrono::system_clock
struct
中返回一个类型为 t ime_point
的对象。清单 10-18 使用auto
关键字为RunTest
函数中的start
和end
变量推导出这种类型。使用 hig h_resolution_timer::now
功能初始化start
和end
,在for
循环之前初始化start
,在for
循环之后初始化end
。从end
的值中减去start
的值,得到函数执行循环所经过的时间。然后使用chrono::duration_cast
模板将time_point
差异变量转换成可以以人类可读形式表达的表示,在本例中为微秒。
RunTest
函数被调用了三次,不同于main
函数。每个调用都有不同数量的循环迭代要运行,以显示计时代码可用于判断哪一次运行的时间效率最低。图 10-14 显示了在英特尔酷睿 i7-3770 上运行程序时产生的输出。
图 10-14 。输出显示对清单 10-18 中的RunTest
的每个后续调用需要更长时间来执行
duration_cast
可用于将系统时间转换为纳秒、毫秒、秒、分、小时以及微秒。当优化许多计算机编程算法时,微秒精度是你正在寻找的。当比较存储器存储类型对程序效率的影响时,这个方法中使用的计时技术将被证明是有用的。
10-9.了解内存选择对性能的影响
问题
你有一个表现很差的程序,但是你不知道为什么。
解决办法
在现代计算机程序中,没有解决性能问题的灵丹妙药。然而,缺乏对现代计算机内存工作原理的理解会导致程序运行不佳。了解高速缓存未命中对程序性能的影响将有助于您编写性能更好的程序。
它是如何工作的
现代处理器的速度比内存访问延迟快得多。这导致了程序中糟糕的内存访问模式会严重影响处理性能的情况。了解如何构建 C++ 程序以有效利用处理器缓存,对于编写尽可能高性能的程序至关重要。
在现代计算机系统中,从主存储器中读取和写入数据可能需要数百个周期。处理器实现缓存来帮助缓解这个问题。现代 CPU 高速缓存的工作原理是将大量数据同时从主内存读入速度更快的高速缓存。这些块被称为缓存线 。英特尔酷睿 i7-3770 处理器上的 L1 高速缓存行大小为 32KB。处理器一次性将整个 32KB 数据块读入 L1 缓存。如果您正在读取或写入的数据不在高速缓存中,结果就是高速缓存未命中,处理器必须从 L2 高速缓存、L3 高速缓存或系统 RAM 中检索数据。缓存未命中的代价可能非常高,代码中看似无害的错误或选择可能会对性能产生重大影响。清单 10-19 包含一个初始化一些数组的循环和三个具有不同内存访问模式的不同循环。
清单 10-19 。探索内存访问模式的性能影响
#include <chrono>
#include <iostream>
using namespace std;
const int NUM_ROWS{ 10000 };
const int NUM_COLUMNS{ 1000 };
int elements[NUM_ROWS][NUM_COLUMNS];
int* pElements[NUM_ROWS][NUM_COLUMNS];
int main(int argc, char* argv[])
{
for (int i{ 0 }; i < NUM_ROWS; ++i)
{
for (int j{ 0 }; j < NUM_COLUMNS; ++j)
{
elements[i][j] = i*j;
pElements[i][j] = new int{ elements[i][j] };
}
}
auto start = chrono::high_resolution_clock::now();
for (int i{ 0 }; i < NUM_ROWS; ++i)
{
for (int j{ 0 }; j < NUM_COLUMNS; ++j)
{
const int result{ elements[j][i] };
}
}
auto end = chrono::high_resolution_clock::now();
auto difference = end - start;
cout << "Time taken for j then i: "
<< chrono::duration_cast<chrono::microseconds>(difference).count()
<< " microseconds!" << endl;
start = chrono::high_resolution_clock::now();
for (int i{ 0 }; i < NUM_ROWS; ++i)
{
for (int j{ 0 }; j < NUM_COLUMNS; ++j)
{
const int result{ elements[i][j] };
}
}
end = chrono::high_resolution_clock::now();
difference = end - start;
cout << "Time taken for i then j: "
<< chrono::duration_cast<chrono::microseconds>(difference).count()
<< " microseconds!" << endl;
start = chrono::high_resolution_clock::now();
for (int i{ 0 }; i < NUM_ROWS; ++i)
{
for (int j{ 0 }; j < NUM_COLUMNS; ++j)
{
const int result{ *(pElements[i][j]) };
}
}
end = chrono::high_resolution_clock::now();
difference = end - start;
cout << "Time taken for pointers with i then j: "
<< chrono::duration_cast<chrono::microseconds>(difference).count()
<< " microseconds!" << endl;
return 0;
}
清单 10-19 中的第一个循环用于设置两个数组。第一个数组直接存储整数值,第二个数组存储指向整数的指针。每个数组包含 10,000 × 1,000 个唯一元素。
理解多维数组在内存中的布局,理解为什么这个测试会产生与缓存未命中性能问题相关的结果是很重要的。可以认为一个 3 × 2 的阵列的布局如表 10-1 所示。
表 10-1 。3 × 2 阵列的布局
但是电脑内存并不是二维的。数组的元素按照表 10-1 中显示的数字顺序线性排列在内存中。给定一个 4 字节的整数大小,这意味着第 2 行第 1 列中的值可以在第 1 行第 1 列中的值之后 12 个字节找到。将行大小扩展到 10,000,您可以看到下一行开头的元素不可能与前一行位于同一缓存行。
这个事实允许用一个简单的循环来测试高速缓存未命中的性能含义。你可以在清单 10-18 的第二个循环中看到这一点,其中增加的j
值用于遍历列而不是行。第三个循环按照正确的顺序遍历数组。也就是说,它在内存中按线性顺序遍历这些行。第四个循环以线性顺序遍历pElement
数组,但是必须取消对指针的引用才能到达数组中存储的值。结果显示了第一个循环中缓存感知编程的影响,第二个循环中的理想情况,以及第三个循环中不必要的内存间接寻址的结果。图 10-15 显示了这些结果。
图 10-15 。来自清单 10-19 中循环的结果
你可以看到,在无序遍历数组时,我电脑中的处理器完成一个简单循环所需的时间增加了 10 倍。这样的问题会导致程序的口吃和延迟,让用户和客户对你的软件感到沮丧。指针解引用的情况也比直接访问整数的情况慢大约两倍。在大量使用动态内存之前,您应该考虑这一点。
10-10.减少内存碎片
问题
您有一个程序,它要求您在很长一段时间内创建许多小的内存分配,这就引入了内存碎片问题。
解决办法
您可以创建一个小的块分配器,用于将小的分配打包到更大的页面中。
它是如何工作的
将少量分配捆绑在一起的第一步是创建一个包含更大内存页面的类。这个方法向您展示了一种简单的方法,将一个 32KB 的内存页面包装到一个类中,并管理这个池中的内存分配。使用布尔值数组跟踪内存,该数组知道给定的内存块是空闲的还是在使用中。当所有当前页面都已满时,会添加新的内存页面。
这种方法的缺点是所有分配的最小大小为 32 字节。任何小于 32 字节的内存请求都会从当前活动的内存页面中分配一个完整的块。当页面完全清空时,它们也会被释放,以确保程序不会增长到一个高水位,并且永远不会释放不需要的内存。清单 10-20 显示了Page
的类定义。
清单 10-20 。Page
类定义
class Page
{
private:
char m_Memory[1024 * 32];
bool m_Free[1024];
Page* m_pNextPage;
public:
Page();
~Page();
void* Alloc();
bool Free(void* pMem);
bool IsEmpty() const;
};
Page
类定义包含两个数组。有一个char
数组服务于内存分配请求。这个池是一个字节数组,在本例中大小为 32KB。池中有 1,024 个独立的块,每个块的大小为 32 字节。1024 个块被镜像到布尔数组m_Free
中。这个数组用于跟踪一个给定的块是已经分配了还是可以分配。m_pNextPage
指针存储下一页的地址。如果当前页完全被使用,则下一页用于分配块。
该类由五个方法组成:构造函数、析构函数、Alloc
方法、Free
方法和IsEmpty
方法,用于确定页面是否不再使用。清单 10-21 展示了Page
类的构造函数和析构函数的函数体。
清单 10-21 。Page
构造函数和析构函数
Page()
: m_pNextPage{ nullptr }
{
memset(m_Free, 1, 1024);
}
~Page()
{
if (m_pNextPage)
{
delete m_pNextPage;
m_pNextPage = nullptr;
}
}
Page
构造函数负责初始化指向nullptr
的m_pNextPage
指针,并将m_F
ree 数组中的所有元素设置为true
。Page
的析构函数负责删除指向m_pNextPage
的对象指针,如果它已经被分配。
清单 10-22 显示了Page::
Alloc
方法的代码。
清单 10-22 。Page::Alloc
法
void* Alloc()
{
void* pMem{ nullptr };
for (unsigned int i = 0; i < 1024; ++i)
{
if (m_Free[i] == true)
{
m_Free[i] = false;
pMem = &m_Memory[i * 32];
break;
}
}
if (pMem == nullptr)
{
if (m_pNextPage == nullptr)
{
m_pNextPage = new Page();
}
pMem = m_pNextPage->Alloc();
}
return pMem;
}
Alloc
方法负责在页面链表中寻找第一个未使用的内存块。第一步是遍历m_Free
数组,检查每个块,看它当前是否在使用中。如果找到一个空闲块,pMem
返回值被设置为空闲块的地址。该块的布尔值被设置为false
以指示该块现在正在使用中。如果找到一个空闲块,循环就被中断。
如果没有找到空闲块,就必须从另一个内存页面分配内存。如果已经创建了另一个页面,指针m_pNextPage
已经保存了它的地址。如果没有,则创建一个新页面。然后在m_pNextPage
上调用Alloc
方法。此时,Alloc
方法是递归的。它会被反复调用,直到找到一个包含空闲内存块的内存页,以将堆栈向上返回到调用代码。从一个页面返回的内存也必须在不再需要时返回到那个页面。清单 10-23 中的Free
方法负责执行这项任务。
清单 10-23 。Page::Free
法
bool Free(void* pMem)
{
bool freed{ false };
bool inPage{ pMem >= m_Memory && pMem <= &m_Memory[(NUM_PAGES * BLOCK_SIZE) - 1] };
if (inPage)
{
unsigned int index{
(reinterpret_cast<unsigned int>(pMem)-reinterpret_cast<unsigned int>(m_Memory))
/ BLOCK_SIZE };
m_Free[index] = true;
freed = true;
}
else if (m_pNextPage)
{
freed = m_pNextPage->Free(pMem);
if (freed && m_pNextPage->IsEmpty())
{
Page* old = m_pNextPage;
m_pNextPage = old->m_pNextPage;
old->m_pNextPage = nullptr;
delete m_pNextPage;
}
}
return freed;
}
Page::Free
方法首先检查被释放的内存地址是否包含在当前页面中。它通过将该地址与内存页面开始的地址和页面中最后一个块的地址进行比较来实现这一点。如果被释放的内存大于或等于页面地址,并且小于或等于页面中的最后一个块,则内存是从该页面分配的。在这种情况下,该块的m_Free
布尔值可以被设置回true
。内存本身不需要清除,因为new
不保证它返回的内存中包含的值,这是调用者的责任。
如果在当前的Page
中没有找到内存,那么Free
方法检查Page
是否有指向另一个Page
对象的指针。如果是,那么在那个Page
上调用Free
方法。与Alloc
方法一样,Free
方法本质上是递归的。如果对m_pN
extPage 上的Free
的调用返回了一个true
值,则检查Page
以查看它现在是否为空。如果是,则可以释放Page
。因为Page
使用一个简单的链接列表来跟踪页面,你必须确保你没有孤立列表的尾部。你需要确保当前页面的m_pNextPage
指针被设置为指向被释放的Page
的m_pNextPage
指针。在Free
方法中调用IsEmpty
方法;这个方法的主体如清单 10-24 所示。
清单 10-24 。Page::IsEmpty
法
bool IsEmpty() const
{
bool isEmpty{ true };
for (unsigned int i = 0; i < NUM_PAGES; ++i)
{
if (m_Free[i] == false)
{
isEmpty = false;
break;
}
}
return isEmpty;
}
IsEmpty
方法检查空闲列表以确定页面当前是否被使用。如果Page
中的任何一个块不空闲,那么Page
就不是空的。页面链表是通过另一个名为SmallBlockAllocator
的类来访问的。这简化了调用代码的页面管理。清单 10-25 显示了SmallBlockAllocator
类。
清单 10-25 。SmallBlockAllocator
类
class SmallBlockAllocator
{
public:
static const unsigned int BLOCK_SIZE{ 32 };
private:
static const unsigned int NUM_ BLOCKS { 1024 };
static const unsigned int PAGE_SIZE{ NUM_ BLOCKS * BLOCK_SIZE };
class Page
{
private:
char m_Memory[PAGE_SIZE];
bool m_Free[NUM_ BLOCKS];
Page* m_pNextPage;
public:
Page()
: m_pNextPage{ nullptr }
{
memset(m_Free, 1, NUM_ BLOCKS);
}
~Page()
{
if (m_pNextPage)
{
delete m_pNextPage;
m_pNextPage = nullptr;
}
}
void* Alloc()
{
void* pMem{ nullptr };
for (unsigned int i = 0; i < NUM_ BLOCKS; ++i)
{
if (m_Free[i] == true)
{
m_Free[i] = false;
pMem = &m_Memory[i * BLOCK_SIZE];
break;
}
}
if (pMem == nullptr)
{
if (m_pNextPage == nullptr)
{
m_pNextPage = new Page();
}
pMem = m_pNextPage->Alloc();
}
return pMem;
}
bool Free(void* pMem)
{
bool freed{ false };
bool inPage{ pMem >= m_Memory &&
pMem <= &m_Memory[(NUM_ BLOCKS * BLOCK_SIZE) - 1] };
if (inPage)
{
unsigned int index{
(reinterpret_cast<unsigned int>(pMem)-
reinterpret_cast<unsigned int>(m_Memory)) / BLOCK_SIZE };
m_Free[index] = true;
freed = true;
}
else if (m_pNextPage)
{
freed = m_pNextPage->Free(pMem);
if (freed && m_pNextPage->IsEmpty())
{
Page* old = m_pNextPage;
m_pNextPage = old->m_pNextPage;
old->m_pNextPage = nullptr;
delete m_pNextPage;
}
}
return freed;
}
bool IsEmpty() const
{
bool isEmpty{ true };
for (unsigned int i = 0; i < NUM_BLOCKS; ++i)
{
if (m_Free[i] == false)
{
isEmpty = false;
break;
}
}
return isEmpty;
}
};
Page m_FirstPage;
public:
SmallBlockAllocator() = default;
void* Alloc()
{
return m_FirstPage.Alloc();
}
bool Free(void* pMem)
{
return m_FirstPage.Free(pMem);
}
};
在清单 10-25 的中,Page
类可以被视为SmallBlockAllocator
的内部类。这有助于确保只有SmallBlockAllocator
本身可以用作Page
对象的接口。SmallBlockAllocator
首先创建静态常量来控制块的大小和每个Page
包含的块数。从SmallBlockAllocator
公开的公共方法只有一个Alloc
方法和一个Free
方法。这些简单地包装对Page::Alloc
和Page::Free
的调用,并在成员m_FirstPage
上调用。这意味着SmallBlockAllocator
类总是至少有一页内存分配给小的分配,并且只要SmallBlockAllocator
处于活动状态,这一页就会驻留在你的程序中。
清单 10-26 显示了重载的new
和delete
操作符,它们需要将小额分配路由到SmallBlockAllocator
。
清单 10-26 。将小额分配路由至SmallBlockAllocator
static SmallBlockAllocator sba;
void* operator new(unsigned int numBytes)
{
void* pMem{ nullptr };
if (numBytes <= SmallBlockAllocator::BLOCK_SIZE)
{
pMem = sba.Alloc();
}
else
{
pMem = malloc(numBytes);
}
return pMem;
}
void* operator new[](unsigned int numBytes)
{
void* pMem{ nullptr };
if (numBytes <= SmallBlockAllocator::BLOCK_SIZE)
{
pMem = sba.Alloc();
}
else
{
pMem = malloc(numBytes);
}
return pMem;
}
void operator delete(void* pMemory)
{
if (!sba.Free(pMemory))
{
free(pMemory);
}
}
void operator delete[](void* pMemory)
{
if (!sba.Free(pMemory))
{
free(pMemory);
}
}
清单 10-26 中的new
和new[]
操作符根据SmallBlockAllocator
类支持的块大小检查分配的字节数。如果被请求的内存大小小于或等于 SBA 的块大小,则在static sba
对象上调用Alloc
方法。如果它较大,则使用malloc
。两个delete
函数都在sba
上调用Free
。如果Free
返回false
,那么被释放的内存不存在于任何小块页面中,并且使用free
函数被释放。
这涵盖了实现一个简单的小块分配器所需的所有代码。清单 10-27 展示了使用这个类的一个工作示例程序的完整清单。
清单 10-27 。 一个工作的小块分配器例子
#include <cstdlib>
#include <iostream>
using namespace std;
class SmallBlockAllocator
{
public:
static const unsigned int BLOCK_SIZE{ 32 };
private:
static const unsigned int NUM_BLOCKS{ 1024 };
static const unsigned int PAGE_SIZE{ NUM_BLOCKS * BLOCK_SIZE };
class Page
{
private:
char m_Memory[PAGE_SIZE];
bool m_Free[NUM_BLOCKS];
Page* m_pNextPage;
public:
Page()
: m_pNextPage{ nullptr }
{
memset(m_Free, 1, NUM_BLOCKS);
}
~Page()
{
if (m_pNextPage)
{
delete m_pNextPage;
m_pNextPage = nullptr;
}
}
void* Alloc()
{
void* pMem{ nullptr };
for (unsigned int i{ 0 }; i < NUM_BLOCKS; ++i)
{
if (m_Free[i] == true)
{
m_Free[i] = false;
pMem = &m_Memory[i * BLOCK_SIZE];
break;
}
}
if (pMem == nullptr)
{
if (m_pNextPage == nullptr)
{
m_pNextPage = new Page();
}
pMem = m_pNextPage->Alloc();
}
return pMem;
}
bool Free(void* pMem)
{
bool freed{ false };
bool inPage{ pMem >= m_Memory &&
pMem <= &m_Memory[(NUM_BLOCKS * BLOCK_SIZE) - 1] };
if (inPage)
{
unsigned int index{
(reinterpret_cast<unsigned int>(pMem)-
reinterpret_cast<unsigned int>(m_Memory)) / BLOCK_SIZE };
m_Free[index] = true;
freed = true;
}
else if (m_pNextPage)
{
freed = m_pNextPage->Free(pMem);
if (freed && m_pNextPage->IsEmpty())
{
Page* old = m_pNextPage;
m_pNextPage = old->m_pNextPage;
old->m_pNextPage = nullptr;
delete m_pNextPage;
}
}
return freed;
}
bool IsEmpty() const
{
bool isEmpty{ true };
for (unsigned int i{ 0 }; i < NUM_BLOCKS; ++i)
{
if (m_Free[i] == false)
{
isEmpty = false;
break;
}
}
return isEmpty;
}
};
Page m_FirstPage;
public:
SmallBlockAllocator() = default;
void* Alloc()
{
return m_FirstPage.Alloc();
}
bool Free(void* pMem)
{
return m_FirstPage.Free(pMem);
}
};
static SmallBlockAllocator sba;
void* operator new(size_t numBytes, const std::nothrow_t& tag) noexcept
{
void* pMem{ nullptr };
if (numBytes <= SmallBlockAllocator::BLOCK_SIZE)
{
pMem = sba.Alloc();
}
else
{
pMem = malloc(numBytes);
}
return pMem;
}
void* operator new[](size_t numBytes, const std::nothrow_t& tag) noexcept
{
void* pMem{ nullptr };
if (numBytes <= SmallBlockAllocator::BLOCK_SIZE)
{
pMem = sba.Alloc();
}
else
{
pMem = malloc(numBytes);
}
return pMem;
}
void operator delete(void* pMemory)
{
if (!sba.Free(pMemory))
{
free(pMemory);
}
}
void operator delete[](void* pMemory)
{
if (!sba.Free(pMemory))
{
free(pMemory);
}
}
int main(int argc, char* argv[])
{
const unsigned int NUM_ALLOCS{ 2148 };
int* pInts[NUM_ALLOCS];
for (unsigned int i{ 0 }; i < NUM_ALLOCS; ++i)
{
pInts[i] = new int;
*pInts[i] = i;
}
for (unsigned int i{ 0 }; i < NUM_ALLOCS; ++i)
{
delete pInts[i];
pInts[i] = nullptr;
}
return 0;
}
十一、并发
CPU 制造商最近努力以 20 世纪 90 年代可能达到的速度提高 CPU 频率。通过巧妙的 CPU 设计和在单个芯片上包含多个处理器,CPU 性能一直在不断提高。这意味着今天的程序员如果希望他们的程序在现代计算机芯片上快速运行,就必须采用并发编程或多线程编程。
对于程序员来说,并发编程可能是一个挑战。许多陷阱等待着并发程序,包括数据不同步,因此是错误的,以及一旦您的任务需要使用锁来管理访问时的死锁。本章的食谱向你介绍了 C++ 提供的 STL 特性的一些实际应用,以帮助你编写并发程序。
11-1.使用线程执行并发任务
问题
您正在编写一个性能很差的程序,并且您希望通过在一个系统中使用多个处理器来加快执行速度。
解决办法
C++ 提供了thread
类型,可以用来创建本地操作系统线程。程序线程可以在多个处理器上运行,因此允许您编写可以使用多个 CPU 和 CPU 内核的程序。
它是如何工作的
检测逻辑 CPU 核心的数量
C++ 线程库提供了一个特性集,允许程序使用给定计算机系统中所有可用的内核和 CPU。您应该知道的 C++ 线程功能提供的第一个重要功能允许您查询计算机包含的执行单元的数量。清单 11-1 展示了 C++ thread::hardware_concurrency
方法 。
清单 11-1 。thread::hardware_concurrency
法
#include <iostream>
#include <thread>
using namespace std;
int main(int argc, char* argv[])
{
const unsigned int numberOfProcessors{ thread::hardware_concurrency() };
cout << "This system can run " << numberOfProcessors << " concurrent tasks" << endl;
return 0;
}
这段代码使用thread::hardware_concurrency
方法来查询执行程序的计算机上可以同时运行的线程数量。图 11-1 显示了这个程序在我的台式电脑上生成的输出。
图 11-1 。在英特尔酷睿 i7 3770 上调用thread::hardware_concurrency
的结果
在采用英特尔酷睿 i5 4200U 处理器的 Surface Pro 2 上运行相同的代码,会返回 4 的值,而酷睿 i7 3770 返回 8 的值。你可以在图 11-2 中看到 Surface Pro 2 给出的结果。
图 11-2 。在 Surface Pro 2 上运行清单 11-1 的结果
在逻辑内核太少的计算机上运行太多线程会导致计算机无响应,因此在创建程序时记住这一点很重要。
创建线程
一旦您知道您正在运行的系统可能会从使用并发执行中受益,您就可以使用 C++ thread
类来创建要在多个处理器内核上运行的任务。thread
类是一个可移植的内置类型,允许您为任何操作系统编写多线程代码。
注意
thread
类是 C++ 编程语言的新成员。它是在 C++11 语言规范中添加的,所以您可能需要查看您正在使用的 STL 库的文档,以确保它支持这个特性。
线程构造函数使用简单,并接受一个函数在另一个 CPU 内核上执行。清单 11-2 显示了一个简单的thread
输出到控制台。
清单 11-2 。创建一个thread
#include <iostream>
#include <thread>
using namespace std;
void ThreadTask()
{
for (unsigned int i{ 0 }; i < 20; ++i)
{
cout << "Output from thread" << endl;
}
}
int main(int argc, char* argv[])
{
const unsigned int numberOfProcessors{ thread::hardware_concurrency() };
cout << "This system can run " << numberOfProcessors << " concurrent tasks" << endl;
if (numberOfProcessors > 1)
{
thread myThread{ ThreadTask };
cout << "Output from main" << endl;
myThread.join();
}
else
{
cout << "CPU does not have multiple cores." << endl;
}
return 0;
}
清单 11-2 根据执行程序的计算机上逻辑核心的数量决定是否创建一个线程。
注意大多数操作系统都允许你运行比处理器数量更多的线程,但是你可能会发现这样做会降低你的程序速度,因为管理多线程的开销很大。
如果 CPU 有不止一个逻辑核心,程序会创建一个名为myThread
的thread
对象。myThread
变量用一个指向函数的指针初始化。这个函数将在线程上下文中执行,并且很可能在与main
函数不同的 CPU 线程上执行。
ThreadTask
功能由一个for
循环组成,简单地多次输出到控制台。main
功能也输出到控制台。目的是显示两种功能同时运行。你可以在图 11-3 的中看到这一点,其中main
的输出出现在ThreadTask
输出的中间。
图 11-3 。输出显示清单 11-2 中的main
和ThreadTask
同时运行
线程后清理
清单 11-2 中的main
函数立即调用线程上的join
方法。join
方法用于告诉当前线程等待附加线程结束执行后再继续。这一点很重要,因为 C++ 程序需要销毁自己的线程来防止泄漏的发生。在一个thread
对象上调用析构函数不会破坏当前正在执行的thread
上下文。清单 11-3 显示的代码已经被修改为不在myThread
调用join
。
清单 11-3 。忘记在thread
上调用join
#include <iostream>
#include <thread>
using namespace std;
void ThreadTask()
{
for (unsigned int i{ 0 }; i < 20; ++i)
{
cout << "Output from thread" << endl;
}
}
int main(int argc, char* argv[])
{
const unsigned int numberOfProcessors{ thread::hardware_concurrency() };
cout << "This system can run " << numberOfProcessors << " concurrent tasks" << endl;
if (numberOfProcessors > 1)
{
thread myThread{ ThreadTask };
cout << "Output from main" << endl;
}
else
{
cout << "CPU does not have multiple cores." << endl;
}
return 0;
}
这段代码导致myThread
对象在ThreadTask
函数完成执行之前超出范围。这会导致程序中的线程泄漏,最终可能导致程序或操作系统变得不稳定。运行在 Linux 命令行上的程序将会失败,并显示如图图 11-4 所示的错误。
图 11-4 。在完成前调用thread
析构函数时的 Linux 错误
正如您所看到的,这个警告不是特别具有描述性,而且也不能保证您在使用其他操作系统和库时会得到任何警告。因此,了解您的线程的生存期并确保您恰当地处理它们是非常重要的。
一种方法是使用join
方法让程序在关闭线程之前等待线程完成。C++ 还提供了第二个选项:detach
方法。清单 11-4 显示了正在使用的detach
方法。
清单 11-4 。使用detach
方法
#include <iostream>
#include <thread>
using namespace std;
void ThreadTask()
{
for (unsigned int i = 0; i < 20; ++i)
{
cout << "Output from thread" << endl;
}
}
int main(int argc, char* argv[])
{
const unsigned int numberOfProcessors{ thread::hardware_concurrency() };
cout << "This system can run " << numberOfProcessors << " concurrent tasks" << endl;
if (numberOfProcessors > 1)
{
thread myThread{ ThreadTask };
cout << "Output from main" << endl;
myThread.detach();
}
else
{
cout << "CPU does not have multiple cores." << endl;
}
return 0;
}
清单 11-4 显示了detach
方法可以用来代替join
。join
方法使程序在继续之前等待一个正在运行的线程完成,但是detach
方法不会。detach
方法允许你创建线程,它们比你的程序执行的时间更长。这对于需要长时间跟踪时间的系统任务可能很有用;然而,我怀疑许多日常程序是否会使用这种方法。还有一个风险是你的程序会泄漏已经被分离的线程,并且没有办法取回那些任务。一旦线程中的执行上下文被分离,您就永远不能重新附加它。
11-2.创建线程范围变量
问题
您有在实现中使用静态数据的对象类,并且您希望将它们与线程一起使用。
解决办法
C++ 提供了thread_local
说明符,允许计算机在每个线程的基础上创建静态数据的实例。
它是如何工作的
在我介绍如何使用thread_local
之前,让我们先来看一个可能出现这个问题的场景,这样你就可以清楚地看到这个问题以及解决方案本身可能导致的问题。清单 11-5 包含了一个类,它使用对象的静态向量来阻止对new
和delete
的多次调用。
清单 11-5 。创建使用静态数据跟踪状态的类
#include <cstdlib>
#include <iostream>
#include <stack>
#include <thread>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static const unsigned int MAX_OBJECTS{ 4 };
using MyManagedObjectCollection = vector < MyManagedObject > ;
static MyManagedObjectCollection s_ManagedObjects;
static stack<unsigned int> s_FreeList;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
void* operator new(size_t numBytes)
{
void* objectMemory{};
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
return objectMemory;
}
void operator delete(void* pMem)
{
const intptr_t index{
(static_cast<MyManagedObject*>(pMem) - s_ManagedObjects.data()) /
static_cast<intptr_t>(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast<intptr_t>(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
}
};
MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
stack<unsigned int> MyManagedObject::s_FreeList{};
int main(int argc, char* argv[])
{
cout << hex << showbase;
MyManagedObject* pObject1{ new MyManagedObject(1) };
cout << "pObject1: " << pObject1 << endl;
MyManagedObject* pObject2{ new MyManagedObject(2) };
cout << "pObject2: " << pObject2 << endl;
delete pObject1;
pObject1 = nullptr;
MyManagedObject* pObject3{ new MyManagedObject(3) };
cout << "pObject3: " << pObject3 << endl;
pObject1 = new MyManagedObject(4);
cout << "pObject1: " << pObject1 << endl;
delete pObject2;
pObject2 = nullptr;
delete pObject3;
pObject3 = nullptr;
delete pObject1;
pObject1 = nullptr;
return 0;
}
清单 11-5 中的代码重载了MyManagedObject
类上的new
和delete
方法。这些重载用于从预分配内存的初始池中返回新创建的对象。这样做将允许你把一个给定类型的对象的数量限制在一个预先安排好的限度内,但仍然允许你使用熟悉的new
和delete
语法。
注意清单 11-5 中的代码实际上并不强制限制;当达到限制时,它简单地退回到动态分配。
托管类通过使用常数来确定应该存在的预分配对象的数量。该数字用于在第一次分配时初始化向量。每次后续分配都从这个向量开始,直到用完为止。维护索引的自由列表。如果池中的一个对象被释放,它的索引将被添加到自由堆栈的顶部。空闲列表中的对象按照它们被添加到堆栈中的顺序重新发布。图 11-5 显示pObject3
以pObject1
被删除前使用的相同地址结束。
图 11-5 。显示MyManagedObject
池正确运行的输出
这个托管池的操作使用一个static vector
和一个static stack
来跨所有MyManagedObject
实例维护该池。这在与线程耦合时会产生问题,因为你不能确定不同的线程不会同时尝试访问这些对象。
清单 11-6 更新了来自清单 11-5 的代码,使用一个thread
来创建MyManagedObject
实例。
清单 11-6 。使用一个thread
来创建MyManagedObject
实例
#include <cstdlib>
#include <iostream>
#include <stack>
#include <thread>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static const unsigned int MAX_OBJECTS{ 8 };
using MyManagedObjectCollection = vector < MyManagedObject >;
static MyManagedObjectCollection s_ManagedObjects;
static stack<unsigned int> s_FreeList;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
void* operator new(size_t numBytes)
{
void* objectMemory{};
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
return objectMemory;
}
void operator delete(void* pMem)
{
const intptr_t index{
(static_cast<MyManagedObject*>(pMem)-s_ManagedObjects.data()) /
static_cast< intptr_t >(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast< intptr_t >(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
}
};
MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
stack<unsigned int> MyManagedObject::s_FreeList{};
void ThreadTask()
{
MyManagedObject* pObject4{ new MyManagedObject(5) };
cout << "pObject4: " << pObject4 << endl;
MyManagedObject* pObject5{ new MyManagedObject(6) };
cout << "pObject5: " << pObject5 << endl;
delete pObject4;
pObject4 = nullptr;
MyManagedObject* pObject6{ new MyManagedObject(7) };
cout << "pObject6: " << pObject6 << endl;
pObject4 = new MyManagedObject(8);
cout << "pObject4: " << pObject4 << endl;
delete pObject5;
pObject5 = nullptr;
delete pObject6;
pObject6 = nullptr;
delete pObject4;
pObject4 = nullptr;
}
int main(int argc, char* argv[])
{
cout << hex << showbase;
thread myThread{ ThreadTask };
MyManagedObject* pObject1{ new MyManagedObject(1) };
cout << "pObject1: " << pObject1 << endl;
MyManagedObject* pObject2{ new MyManagedObject(2) };
cout << "pObject2: " << pObject2 << endl;
delete pObject1;
pObject1 = nullptr;
MyManagedObject* pObject3{ new MyManagedObject(3) };
cout << "pObject3: " << pObject3 << endl;
pObject1 = new MyManagedObject(4);
cout << "pObject1: " << pObject1 << endl;
delete pObject2;
pObject2 = nullptr;
delete pObject3;
pObject3 = nullptr;
delete pObject1;
pObject1 = nullptr;
myThread.join();
return 0;
}
清单 11-6 中的代码使用一个thread
与main
函数同时从池中分配对象。这意味着可以从两个位置同时访问静态池,您的程序可能会遇到问题。两个常见的问题是意外的程序崩溃和数据竞争。
数据竞争 是一个更微妙的问题,会导致意外的内存损坏。图 11-6 说明了这个问题。
图 11-6 。线程间发生数据竞争所导致的问题
从同一个池中分配对象所带来的问题可能很微妙,一开始很难发现。如果你仔细观察,你会发现pObject6
和pObject3
指向同一个内存地址。这些指针是在不同的线程上创建和初始化的,你永远也不会期望它们指向同一个内存地址,即使在你的池中有对象重用。这也是使用螺纹的一个难点。相关的问题是非常时间敏感的,它们的表现形式可以被执行时的计算机条件所改变。其他程序可能会创建导致您自己的线程稍微延迟的线程,因此您的线程逻辑中的问题可能会以许多不同的方式表现出来,尽管其根本原因是相同的。
C++ 为这个问题提供了一个解决方案:thread_local
关键字。thread_local
关键字告诉编译器,你创建的static
对象对于你创建的每个使用这些对象的thread
应该是唯一的。副作用是您没有一个跨所有类的静态对象的共享实例。这与static
的正常用法有很大的不同,在正常用法中,该类型的所有实例只有一个共享对象。清单 11-7 显示了内存池函数和相关的static
变量被更新为使用thread_local
。
清单 11-7 。使用thread_local
#include <cstdlib>
#include <iostream>
#include <stack>
#include <thread>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static thread_local const unsigned int MAX_OBJECTS;
using MyManagedObjectCollection = vector < MyManagedObject >;
static thread_local MyManagedObjectCollection s_ManagedObjects;
static thread_local stack<unsigned int> s_FreeList;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
void* operator new(size_t numBytes)
{
void* objectMemory{};
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
return objectMemory;
}
void operator delete(void* pMem)
{
const intptr_t index{
(static_cast<MyManagedObject*>(pMem)-s_ManagedObjects.data()) /
static_cast<intptr_t>(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast< intptr_t >(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
}
};
thread_local const unsigned int MyManagedObject::MAX_OBJECTS{ 8 };
thread_local MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
thread_local stack<unsigned int> MyManagedObject::s_FreeList{};
void ThreadTask()
{
MyManagedObject* pObject4{ new MyManagedObject(5) };
cout << "pObject4: " << pObject4 << endl;
MyManagedObject* pObject5{ new MyManagedObject(6) };
cout << "pObject5: " << pObject5 << endl;
delete pObject4;
pObject4 = nullptr;
MyManagedObject* pObject6{ new MyManagedObject(7) };
cout << "pObject6: " << pObject6 << endl;
pObject4 = new MyManagedObject(8);
cout << "pObject4: " << pObject4 << endl;
delete pObject5;
pObject5 = nullptr;
delete pObject6;
pObject6 = nullptr;
delete pObject4;
pObject4 = nullptr;
}
int main(int argc, char* argv[])
{
cout << hex << showbase;
thread myThread{ ThreadTask };
MyManagedObject* pObject1{ new MyManagedObject(1) };
cout << "pObject1: " << pObject1 << endl;
MyManagedObject* pObject2{ new MyManagedObject(2) };
cout << "pObject2: " << pObject2 << endl;
delete pObject1;
pObject1 = nullptr;
MyManagedObject* pObject3{ new MyManagedObject(3) };
cout << "pObject3: " << pObject3 << endl;
pObject1 = new MyManagedObject(4);
cout << "pObject1: " << pObject1 << endl;
delete pObject2;
pObject2 = nullptr;
delete pObject3;
pObject3 = nullptr;
delete pObject1;
pObject1 = nullptr;
myThread.join();
return 0;
}
清单 11-7 显示了您可以通过在声明和定义中添加thread_local
标识符来指定static
变量具有thread_local
存储。这一变化的影响是主函数和ThreadTask
函数在它们自己的执行上下文中有单独的s_ManagedObjects
、s_FreeList
和MAX_OBJECT
变量。既然每个都有两个副本,您就有了两倍数量的潜在对象,因为池已经被复制了。这对你的程序来说可能是也可能不是问题,但是你在使用thread_local
时应该小心,并考虑任何意想不到的后果。图 11-7 显示了运行清单 11-7 中代码的结果。
图 11-7 。使用thread_local
时的输出
你可以在使用螺纹时看到问题。第一行输出在两个线程之间分配,但是很明显两个线程被分配了内存中完全不同位置的值。这证明编译器已经确保static
变量对于程序中的每个thread
是唯一的。你可以更进一步,给程序添加更多的线程,并看到它们从内存的不同位置分配对象,并且不同线程上的两个指针决不能指向同一个内存地址。
11-3.使用互斥访问共享对象
问题
您有一个对象,您希望能够在不止一个线程上同时访问它。
解决办法
C++ 提供了mutex
对象,允许您提供对代码段的互斥访问。
它是如何工作的
互斥体可以用来同步线程。这是通过mutex
类及其提供的获取和释放互斥体的方法来实现的。在继续执行之前,一个线程可以通过等待直到它可以获得互斥体来确定当前没有其他线程正在访问共享资源。清单 11-8 中的程序包含一个数据竞争:两个线程可以同时访问一个共享资源并导致不稳定和意外的程序行为。
清单 11-8 。包含数据竞赛的程序
#include <cstdlib>
#include <iostream>
#include <stack>
#include <thread>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static const unsigned int MAX_OBJECTS{ 8 };
using MyManagedObjectCollection = vector < MyManagedObject >;
static MyManagedObjectCollection s_ManagedObjects;
static stack<unsigned int> s_FreeList;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
void* operator new(size_t numBytes)
{
void* objectMemory{};
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
return objectMemory;
}
void operator delete(void* pMem)
{
const intptr_t index{
(static_cast<MyManagedObject*>(pMem)-s_ManagedObjects.data()) /
static_cast<intptr_t>(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast< intptr_t >(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
}
};
MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
stack<unsigned int> MyManagedObject::s_FreeList{};
void ThreadTask()
{
MyManagedObject* pObject4{ new MyManagedObject(5) };
cout << "pObject4: " << pObject4 << endl;
MyManagedObject* pObject5{ new MyManagedObject(6) };
cout << "pObject5: " << pObject5 << endl;
delete pObject4;
pObject4 = nullptr;
MyManagedObject* pObject6{ new MyManagedObject(7) };
cout << "pObject6: " << pObject6 << endl;
pObject4 = new MyManagedObject(8);
cout << "pObject4: " << pObject4 << endl;
delete pObject5;
pObject5 = nullptr;
delete pObject6;
pObject6 = nullptr;
delete pObject4;
pObject4 = nullptr;
}
int main(int argc, char* argv[])
{
cout << hex << showbase;
thread myThread{ ThreadTask };
MyManagedObject* pObject1{ new MyManagedObject(1) };
cout << "pObject1: " << pObject1 << endl;
MyManagedObject* pObject2{ new MyManagedObject(2) };
cout << "pObject2: " << pObject2 << endl;
delete pObject1;
pObject1 = nullptr;
MyManagedObject* pObject3{ new MyManagedObject(3) };
cout << "pObject3: " << pObject3 << endl;
pObject1 = new MyManagedObject(4);
cout << "pObject1: " << pObject1 << endl;
delete pObject2;
pObject2 = nullptr;
delete pObject3;
pObject3 = nullptr;
delete pObject1;
pObject1 = nullptr;
myThread.join();
return 0;
}
这个程序不能阻止ThreadTask
中的代码和main
函数访问MyManagedObject class
中的s_ManagedObjects
和s_FreeList
池。对这些对象的访问可以由一个互斥体来保护,正如你在清单 11-9 中看到的。
清单 11-9 。添加互斥体以保护对共享对象的访问
#include <cstdlib>
#include <iostream>
#include <mutex>
#include <stack>
#include <thread>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static const unsigned int MAX_OBJECTS{ 8 };
using MyManagedObjectCollection = vector < MyManagedObject >;
static MyManagedObjectCollection s_ManagedObjects;
static stack<unsigned int> s_FreeList;
static mutex s_Mutex;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
void* operator new(size_t numBytes)
{
void* objectMemory{};
s_Mutex.lock();
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
s_Mutex.unlock();
return objectMemory;
}
void operator delete(void* pMem)
{
s_Mutex.lock();
const intptr_t index{
(static_cast<MyManagedObject*>(pMem)-s_ManagedObjects.data()) /
static_cast<intptr_t>(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast< intptr_t >(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
s_Mutex.unlock();
}
};
MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
stack<unsigned int> MyManagedObject::s_FreeList{};
mutex MyManagedObject::s_Mutex;
void ThreadTask()
{
MyManagedObject* pObject4{ new MyManagedObject(5) };
cout << "pObject4: " << pObject4 << endl;
MyManagedObject* pObject5{ new MyManagedObject(6) };
cout << "pObject5: " << pObject5 << endl;
delete pObject4;
pObject4 = nullptr;
MyManagedObject* pObject6{ new MyManagedObject(7) };
cout << "pObject6: " << pObject6 << endl;
pObject4 = new MyManagedObject(8);
cout << "pObject4: " << pObject4 << endl;
delete pObject5;
pObject5 = nullptr;
delete pObject6;
pObject6 = nullptr;
delete pObject4;
pObject4 = nullptr;
}
int main(int argc, char* argv[])
{
cout << hex << showbase;
thread myThread{ ThreadTask };
MyManagedObject* pObject1{ new MyManagedObject(1) };
cout << "pObject1: " << pObject1 << endl;
MyManagedObject* pObject2{ new MyManagedObject(2) };
cout << "pObject2: " << pObject2 << endl;
delete pObject1;
pObject1 = nullptr;
MyManagedObject* pObject3{ new MyManagedObject(3) };
cout << "pObject3: " << pObject3 << endl;
pObject1 = new MyManagedObject(4);
cout << "pObject1: " << pObject1 << endl;
delete pObject2;
pObject2 = nullptr;
delete pObject3;
pObject3 = nullptr;
delete pObject1;
pObject1 = nullptr;
myThread.join();
return 0;
}
这段代码使用一个互斥体来确保MyManagedObject
类中的new
和delete
函数在任何给定时间都只在一个线程上执行。这确保了为这个类维护的对象池总是处于有效状态,并且相同的地址不会被给予不同的线程。代码要求在它所保护的函数的整个执行过程中都保持锁。C++ 提供了一个名为lock_guard
的助手类,它在构造时自动锁定一个互斥体,在销毁时释放互斥体。清单 11-10 显示了一个lock_
guard
在使用。
清单 11-10 。使用lock_guard
#include <cstdlib>
#include <iostream>
#include <mutex>
#include <stack>
#include <thread>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static const unsigned int MAX_OBJECTS{ 8 };
using MyManagedObjectCollection = vector < MyManagedObject >;
static MyManagedObjectCollection s_ManagedObjects;
static stack<unsigned int> s_FreeList;
static mutex s_Mutex;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
void* operator new(size_t numBytes)
{
lock_guard<mutex> lock{ s_Mutex };
void* objectMemory{};
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
return objectMemory;
}
void operator delete(void* pMem)
{
lock_guard<mutex> lock{ s_Mutex };
const intptr_t index{
(static_cast<MyManagedObject*>(pMem)-s_ManagedObjects.data()) /
static_cast<intptr_t>(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast<intptr_t>(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
}
};
MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
stack<unsigned int> MyManagedObject::s_FreeList{};
mutex MyManagedObject::s_Mutex;
void ThreadTask()
{
MyManagedObject* pObject4{ new MyManagedObject(5) };
cout << "pObject4: " << pObject4 << endl;
MyManagedObject* pObject5{ new MyManagedObject(6) };
cout << "pObject5: " << pObject5 << endl;
delete pObject4;
pObject4 = nullptr;
MyManagedObject* pObject6{ new MyManagedObject(7) };
cout << "pObject6: " << pObject6 << endl;
pObject4 = new MyManagedObject(8);
cout << "pObject4: " << pObject4 << endl;
delete pObject5;
pObject5 = nullptr;
delete pObject6;
pObject6 = nullptr;
delete pObject4;
pObject4 = nullptr;
}
int main(int argc, char* argv[])
{
cout << hex << showbase;
thread myThread{ ThreadTask };
MyManagedObject* pObject1{ new MyManagedObject(1) };
cout << "pObject1: " << pObject1 << endl;
MyManagedObject* pObject2{ new MyManagedObject(2) };
cout << "pObject2: " << pObject2 << endl;
delete pObject1;
pObject1 = nullptr;
MyManagedObject* pObject3{ new MyManagedObject(3) };
cout << "pObject3: " << pObject3 << endl;
pObject1 = new MyManagedObject(4);
cout << "pObject1: " << pObject1 << endl;
delete pObject2;
pObject2 = nullptr;
delete pObject3;
pObject3 = nullptr;
delete pObject1;
pObject1 = nullptr;
myThread.join();
return 0;
}
使用lock_guard
意味着你不必担心在互斥体上调用unlock
。它也符合许多 C++ 开发人员试图遵循的资源分配初始化(RAII)模式。
11-4.创建等待事件的线程
问题
你想要创建一个线程来等待你程序中的另一个事件。
解决办法
C++ 提供了condition_variable class
,它可以用来通知等待的线程发生了一个事件。
它是如何工作的
condition_variable
是另一个 C++ 构造,它将复杂的行为包装到一个简单的对象接口中。在多线程编程中,创建线程来等待另一个线程中的某个事件发生是很常见的。这在生产者/消费者的情况下很常见,其中一个线程可能正在创建任务,而另一个线程正在拍卖或执行这些任务。在这些场景中,条件变量是完美的。
一个condition_variable
需要一个互斥才能生效。它的工作方式是等待某个条件变为真,然后试图获取保护共享资源的互斥锁。清单 11-11 使用一个互斥体、一个unique_lock
和一个condition_variable
在线程之间进行通信,此时一个生产者线程已经为两个消费者threads
排队了工作。
清单 11-11 。使用condition_variable
唤醒线程
#include <condition_variable>
#include <cstdlib>
#include <functional>
#include <iostream>
#include <mutex>
#include <thread>
#include <stack>
#include <vector>
using namespace std;
class MyManagedObject
{
private:
static const unsigned int MAX_OBJECTS{ 8 };
using MyManagedObjectCollection = vector < MyManagedObject >;
static MyManagedObjectCollection s_ManagedObjects;
static stack<unsigned int> s_FreeList;
static mutex s_Mutex;
unsigned int m_Value{ 0xFFFFFFFF };
public:
MyManagedObject() = default;
MyManagedObject(unsigned int value)
: m_Value{ value }
{
}
unsigned int GetValue() const { return m_Value; }
void* operator new(size_t numBytes)
{
lock_guard<mutex> lock{ s_Mutex };
void* objectMemory{};
if (s_ManagedObjects.capacity() < MAX_OBJECTS)
{
s_ManagedObjects.reserve(MAX_OBJECTS);
}
if (numBytes == sizeof(MyManagedObject) &&
s_ManagedObjects.size() < s_ManagedObjects.capacity())
{
unsigned int index{ 0xFFFFFFFF };
if (s_FreeList.size() > 0)
{
index = s_FreeList.top();
s_FreeList.pop();
}
if (index == 0xFFFFFFFF)
{
s_ManagedObjects.push_back({});
index = s_ManagedObjects.size() - 1;
}
objectMemory = s_ManagedObjects.data() + index;
}
else
{
objectMemory = malloc(numBytes);
}
return objectMemory;
}
void operator delete(void* pMem)
{
lock_guard<mutex> lock{ s_Mutex };
const intptr_t index{
(static_cast<MyManagedObject*>(pMem)-s_ManagedObjects.data()) /
static_cast<intptr_t>(sizeof(MyManagedObject)) };
if (0 <= index && index < static_cast<intptr_t>(s_ManagedObjects.size()))
{
s_FreeList.emplace(static_cast<unsigned int>(index));
}
else
{
free(pMem);
}
}
};
MyManagedObject::MyManagedObjectCollection MyManagedObject::s_ManagedObjects{};
stack<unsigned int> MyManagedObject::s_FreeList{};
mutex MyManagedObject::s_Mutex;
using ProducerQueue = vector < unsigned int > ;
void ThreadTask(
reference_wrapper<condition_variable> condition,
reference_wrapper<mutex> queueMutex,
reference_wrapper<ProducerQueue> queueRef,
reference_wrapper<bool> die)
{
ProducerQueue& queue{ queueRef.get() };
while (!die.get() || queue.size())
{
unique_lock<mutex> lock{ queueMutex.get() };
function<bool()> predicate{
[&queue]()
{
return !queue.empty();
}
};
condition.get().wait(lock, predicate);
unsigned int numberToCreate{ queue.back() };
queue.pop_back();
cout << "Creating " <<
numberToCreate <<
" objects on thread " <<
this_thread::get_id() << endl;
for (unsigned int i = 0; i < numberToCreate; ++i)
{
MyManagedObject* pObject{ new MyManagedObject(i) };
}
}
}
int main(int argc, char* argv[])
{
condition_variable condition;
mutex queueMutex;
ProducerQueue queue;
bool die{ false };
thread myThread1{ ThreadTask, ref(condition), ref(queueMutex), ref(queue), ref(die) };
thread myThread2{ ThreadTask, ref(condition), ref(queueMutex), ref(queue), ref(die) };
queueMutex.lock();
queue.emplace_back(300000);
queue.emplace_back(400000);
queueMutex.unlock();
condition.notify_all();
this_thread::sleep_for( 10ms );
while (!queueMutex.try_lock())
{
cout << "Main waiting for queue access!" << endl;
this_thread::sleep_for( 100ms );
}
queue.emplace_back(100000);
queue.emplace_back(200000);
this_thread::sleep_for( 1000ms );
condition.notify_one();
this_thread::sleep_for( 1000ms );
condition.notify_one();
this_thread::sleep_for( 1000ms );
queueMutex.unlock();
die = true;
cout << "main waiting for join!" << endl;
myThread1.join();
myThread2.join();
return 0;
}
这段代码包含一个使用 C++ 语言多线程功能的复杂场景。您需要理解的这个例子的第一个方面是用于将变量从main
传递到线程的方法。当线程对象被创建时,您可以认为您传递给它的值是通过值传递给函数的。实际上,这导致您的线程接收变量的副本,而不是变量本身。当您试图在线程之间共享对象时,这会造成困难,因为一个线程中的变化不会反映在另一个线程中。您可以通过使用reference_wrapper
模板来克服这个限制。一个reference_
wrapper
本质上存储了一个指向你试图在线程之间共享的对象的指针,但是它通过确保值不能为空来帮助克服你通常必须考虑空指针的问题。当您将变量传递给线程构造函数时,您实际上是将变量传递给了ref
函数,该函数又将包含您的对象的reference_wrapper
传递给了thread
。当线程构造函数复制您传递给它的值时,您收到的是reference_wrapper
的副本,而不是对象本身的副本。您可以通过使用指向对象的指针来获得相同的结果,但是这种内置的 C++ 方法要简单得多,并且提供了更多的安全性。ThreadTask
函数使用reference_wrapper
模板提供的get
方法从它们的reference_wrapper
实例中检索共享对象。
ThreadTask
函数由程序中两个不同的线程使用,因此reference_wrapper
的使用对于确保两个实例共享同一个互斥体和condition_variable
以及main
至关重要。每个实例使用一个unique_lock
来包装互斥体的行为。奇怪的是,一个unique_lock
在构造时会自动锁定一个互斥体,但是清单 11-11 中的代码从来不会对互斥体调用unlock
。首先由wait
方法执行unlock
调用。condition_variable::wait
方法解锁互斥体,并等待另一个线程发出它应该继续的信号。不幸的是,这种等待并不完全可靠,因为一些操作系统可以在没有发送适当信号的情况下决定解锁线程。出于这个原因,有一个备份计划是一个好主意——wait
方法通过接受一个谓词参数提供了这一点。谓词接受一个可以像函数一样调用的变量。清单 11-11 中的代码提供了一个决定队列是否为空的闭包。当线程唤醒时,因为它已经被程序或操作系统通知唤醒,所以它在试图重新获取所提供的互斥体上的锁之前,首先检查谓词是否为真。如果谓词为真,则wait
函数调用lock
并返回;这样做允许线程的函数继续执行。由于while
循环,ThreadTask
功能在重新开始之前创建适当数量的对象。在while
循环的每次迭代结束时,互斥体的unique_lock
包装器超出范围;它的析构函数在互斥体上调用unlock
,允许其他线程被解锁。
注意清单 11-11 中
unique_lock
的使用在技术上是低效的。持有锁的时间长于从队列中检索要创建的对象数量的时间,这实质上是通过在一个线程创建对象时使所有线程同步来串行化对象的创建。这个例子设计得不好,是为了展示这些对象在实践中是如何使用的。
鉴于ThreadTask
函数在两个线程中使用,以消耗来自queue
的作业,main
函数是一个生产者线程,它将作业添加到queue
。它首先创建两个消费者线程来执行它的任务。一旦线程被创建,main
功能继续向queue
添加任务。它锁定互斥,添加两个作业——一个创建 300,000 个对象,另一个创建 400,000 个对象——并解锁互斥。然后它在condition_variable
上调用notify_all
。condition_variable
对象存储等待信号继续的线程的列表;notify_all
方法唤醒所有这些线程,以便它们可以执行工作。然后main
函数使用try_lock
来表明当线程忙碌时它不能添加任务。在普通代码中,您可以调用lock
;但这是一个如何让线程等待一定时间的例子,以及如果互斥无法锁定,如何使用try_lock
方法有条件地执行代码。一旦try_lock
返回真并且在互斥再次解锁之前,更多的任务被添加到queue
中。然后使用notify_one
函数一次唤醒一个线程,以表明可以编写对线程进行更精细控制的代码。第二个线程也必须被唤醒,否则程序将在join
调用时无限期停止。
图 11-8 显示了运行这段代码产生的输出。你可以看到main
在等待访问互斥体时被阻塞,两个线程都被用来消耗来自queue
的任务。
图 11-8 。显示多个线程被条件变量唤醒的输出
11-5.从线程中检索结果
问题
您想要创建一个能够返回结果的线程。
解决办法
C++ 提供了promise
和future
对象,可以用来在线程之间传输数据。
它是如何工作的
使用承诺和未来类
将数据从工作线程的传回开始一项任务的线程可能是一个复杂的过程。您必须确保互斥访问为存储结果而留出的内存,并处理线程之间的所有信号。这些信号包括让工作线程指定线程操作的结果何时可用,以及让调度线程等待该结果可用。现代 C++ 使用promise
模板解决了这个问题。
一个promise
模板可以被一个thread
任务返回类型特殊化。这在线程之间创建了一个契约,允许将这种类型的对象从一个线程转移到另一个线程。一个promise
包含一个future
。这意味着一个promise
可以实现它的名字:它本质上承诺在将来的某个时候向它的future
的持有者提供一个其专用类型的值。不要求在一个以上的线程上使用promise
,但是promise
是线程安全的,非常适合这项工作。promise
/ future
对的另一个用途是从异步操作中检索结果,比如 HTTP 请求。清单 11-12 显示了在单线程上使用promise
。
清单 11-12 。在一个线程上使用promise
#include <future>
#include <iostream>
using namespace std;
using FactorialPromise = promise< long long >;
long long Factorial(unsigned int value)
{
return value == 1
? 1
: value * Factorial(value - 1);
}
int main(int argc, char* argv[])
{
using namespace chrono;
FactorialPromise promise;
future<long long> taskFuture{ promise.get_future() };
promise.set_value(Factorial(3));
cout << "Factorial result was " << taskFuture.get() << endl;
return 0;
}
清单 11-12 展示了使用promise
为一个值提供存储,这个值可以在以后计算和检索。您可以将它用于长期运行的任务,例如从文件中加载数据或从服务器中检索信息。当promise
没有实现时,程序可以继续呈现 UI 或进度条。
用默认的构造函数初始化promise
,您可以使用get_future
方法来获取promise
放置其值的future
。promise
上的set_value
方法设置future
上的值,而future
上的get
方法提供对该值的访问。
当像清单 11-12 中的一样将promise
和future
紧密地放在一起使用时,很难看出它们之间关注点的分离。清单 11-13 通过将promise
移动到另一个线程来克服这个问题。
清单 11-13 。将promise
移动到第二个线程
#include <future>
#include <iostream>
using namespace std;
using FactorialPromise = promise< long long > ;
long long Factorial(unsigned int value)
{
this_thread::sleep_for(chrono::seconds(2));
return value == 1
? 1
: value * Factorial(value - 1);
}
void ThreadTask(FactorialPromise& threadPromise, unsigned int value)
{
threadPromise.set_value(Factorial(value));
}
int main(int argc, char* argv[])
{
using namespace chrono;
FactorialPromise promise;
future<long long> taskFuture{ promise.get_future() };
thread taskThread{ ThreadTask, std::move(promise), 3 };
while (taskFuture.wait_until(system_clock::now() + seconds(1)) != future_status::ready)
{
cout << "Still Waiting!" << endl;
}
cout << "Factorial result was " << taskFuture.get() << endl;
taskThread.join();
return 0;
}
在清单 11-13 中,promise
和future
对象的初始化方式与清单 11-12 中相同;然而,Factorial
函数是使用ThreadTask
函数从线程中调用的。一些额外的行显示了如何使用一个future
来等待完成,而不必阻塞一个线程。Factorial
方法有一个sleep_for
调用,这个调用导致Factorial
的计算比平常花费更长的时间。这考虑到了future::wait_until
方法的例子。这个方法要么等到提供的绝对时间,要么等到承诺已经实现并且可以检索到future
的值。wait_until
方法需要绝对的系统时间来等待;这可以使用system_clock::now
方法和合适的duration
很容易地提供,在这种情况下是一个second
。如果打印“仍在等待!”不存在,那么对future
上的get
的调用将是阻塞调用。这将导致您的线程停止,直到在promise
上调用了set_value
方法。有时这种行为是合适的,而其他时候则不合适。这取决于你正在编写的软件的需求。
一个promise
和一个future
的使用直接依赖于你管理你自己的thread
函数。有时这可能有点矫枉过正,如清单 11-13 中的情况。ThreadTask
函数只有一个工作:给set_value
打电话。C++ 提供了packaged_task
模板,让你无需创建自己的thread
函数。一个packaged_task
构造函数把要调用的函数作为参数;一个对应的thread
构造函数,可以带一个packaged_task
。这样构造的线程可以自动调用提供的packaged_task
中的方法,并在其内部promise
上调用set_value
。清单 11-14 显示了一个packaged_task
的用法。
清单 11-14 。使用packaged_task
#include <future>
#include <iostream>
using namespace std;
long long Factorial(unsigned int value)
{
this_thread::sleep_for(chrono::seconds(2));
return value == 1
? 1
: value * Factorial(value - 1);
}
int main(int argc, char* argv[])
{
using namespace chrono;
packaged_task<long long(unsigned int)> task{ Factorial };
future<long long> taskFuture{ task.get_future() };
thread taskThread{ std::move(task), 3 };
while (taskFuture.wait_until(system_clock::now() + seconds(1)) != future_status::ready)
{
cout << "Still Waiting!" << endl;
}
cout << "Factorial result was " << taskFuture.get() << endl;
taskThread.join();
return 0;
}
清单 11-14 显示当使用packaged_task
时不再需要ThreadTask
函数。packaged_task
构造函数将一个函数指针作为参数。packaged_task
模板还提供了一个get_future
方法,并使用 move 语义传递给一个thread
。
尽管打包的任务消除了对thread
函数的需求,但您仍然必须手动创建自己的线程。C++ 提供了第四层抽象,让你不必担心 ?? 线程。清单 11-15 使用async
函数异步调用一个函数。
清单 11-15 。使用async
调用函数
#include <future>
#include <iostream>
using namespace std;
long long Factorial(unsigned int value)
{
cout << "ThreadTask thread: " << this_thread::get_id() << endl;
return value == 1
? 1
: value * Factorial(value - 1);
}
int main(int argc, char* argv[])
{
using namespace chrono;
cout << "main thread: " << this_thread::get_id() << endl;
auto taskFuture1 = async(Factorial, 3);
cout << "Factorial result was " << taskFuture1.get() << endl;
auto taskFuture2 = async(launch::async, Factorial, 3);
cout << "Factorial result was " << taskFuture2.get() << endl;
auto taskFuture3 = async(launch::deferred, Factorial, 3);
cout << "Factorial result was " << taskFuture3.get() << endl;
auto taskFuture4 = async(launch::async | launch::deferred, Factorial, 3);
cout << "Factorial result was " << taskFuture4.get() << endl;
return 0;
}
清单 11-15 显示了async
函数及其重载版本的不同可能组合,它将launch
枚举作为一个参数。对async
的第一次调用是最简单的:您调用async
并传递给它一个函数和该函数的参数。async
函数返回一个未来值,该值可用于获取提供给async
的函数的返回值。然而,不能保证该函数会在另一个线程上被调用。所有的async
保证的是在创建对象和调用future
上的 get 之间的某个时间调用这个函数。
超载版的async
给你更多的控制权。传递launch::async
保证了该函数将尽快在另一个线程上被调用。这未必是一个全新的线程。async
的实现者可以自由使用他们选择的任何线程。这可能意味着拥有一个可以重用的线程池。另一方面,deferred
选项告诉返回的future
在调用get
时评估提供的函数。这不是一个并发进程,会导致调用 get 的线程阻塞,但这也是特定于实现的,并不是所有 C++ 库都一样。你必须检查你的库的文档或者通过运行和检查执行时间和thread
id 来测试你的代码。
对async
的最后调用使用一个or
同时通过async
和deferred
。这与在没有指定执行策略的情况下调用async
并让实现决定是否应该使用async
或deferred
是一样的。图 11-9 显示了每次调用async
的结果。
图 11-9 。调用async
时使用的线程 id
如您所见,除了明确标记为async
的调用之外,该库对每个调用都使用了main
线程。确保在所有平台和使用的库上测试您的程序,以确保您看到了您期望的行为。
11-6.在线程间同步排队的消息
问题
您有一个线程,您希望它在整个程序期间都存在,并响应它发送的消息。
解决办法
您可以使用function
、bind
、condition_variable
、mutex
和unique_lock
的组合来创建一个双缓冲消息队列,以将工作从一个线程转移到另一个线程。
它是如何工作的
许多程序受益于将显示逻辑与业务逻辑分离(或者,在视频游戏中,将模拟与渲染分离)并在不同的 CPU 内核上运行。最终,这些任务通常可以彼此独立地执行,只要您能够在系统之间定义一个结构良好的边界,并开发一种将数据从一个线程传输到另一个线程的方法。一种方法是创建消息或命令的双缓冲区。业务逻辑线程可以将命令添加到队列中,而显示逻辑线程正在从队列中读取命令。双缓冲队列允许您减少线程之间存在的同步点的数量,以增加两个线程的吞吐量。生产者线程执行工作并将大量任务排队到缓冲区的一侧,而消费者线程正忙于处理最后一组要排队的任务。在任一线程上发生的唯一时间延迟是当一个线程完成并等待另一个线程时。清单 11-16 显示了双缓冲消息队列的类定义。
清单 11-16 。创建双缓冲消息队列
#include <future>
#include <iostream>
using namespace std;
template <typename T>
class MessageQueue
{
private:
using Queue = vector < T > ;
using QueueIterator = typename Queue::iterator;
Queue m_A;
Queue m_B;
Queue* m_Producer{ &m_A };
Queue* m_Consumer{ &m_B };
QueueIterator m_ConsumerIterator{ m_B.end() };
condition_variable& m_MessageCondition;
condition_variable m_ConsumptionFinished;
mutex m_MutexProducer;
mutex m_MutexConsumer;
unsigned int m_SwapCount{ 0 };
public:
MessageQueue(condition_variable& messageCondition)
: m_MessageCondition{ messageCondition }
{
}
unsigned int GetCount() const
{
return m_SwapCount;
}
void Add(T&& operation)
{
unique_lock<mutex> lock{ m_MutexProducer };
m_Producer->insert(m_Producer->end(), std::move(operation));
}
void BeginConsumption()
{
m_MutexConsumer.lock();
}
T Consume()
{
T operation;
if (m_Consumer->size() > 0)
{
operation = *m_ConsumerIterator;
m_ConsumerIterator = m_Consumer->erase(m_ConsumerIterator);
assert(m_ConsumerIterator == m_Consumer->begin());
}
return operation;
}
void EndConsumption()
{
assert(m_Consumer->size() == 0);
m_MutexConsumer.unlock();
m_ConsumptionFinished.notify_all();
}
unsigned int Swap()
{
unique_lock<mutex> lockB{ m_MutexConsumer };
m_ConsumptionFinished.wait(
lockB,
[this]()
{
return m_Consumer->size() == 0;
}
);
unique_lock<mutex> lockA{ m_MutexProducer };
Queue* temp{ m_Producer };
m_Producer = m_Consumer;
m_Consumer = temp;
m_ConsumerIterator = m_Consumer->begin();
m_MessageCondition.notify_all();
return m_SwapCount++;
}
};
清单 11-16 中的类模板是一个功能消息队列,包含一个双缓冲区,用于将对象从一个线程传递到另一个线程。它由两个向量m_A
和m_B
组成,通过指针m_Producer
和m_Consumer
访问。当正确使用时,class
允许跨越Add
和Consume
方法的无阻塞访问。如果你只是简单地从一个线程添加并从另一个线程消耗,你可以缓冲大量的工作,而不必同步线程。两个线程唯一需要同步的时候是生产者线程想要将工作同步到消费者线程的时候。这在Swap
方法中处理。交换方法使用m_ConsumptionFinished condition_variable
等待m_Consumer
队列为空。这里的condition_variable
是通过EndConsumption
方法通知的。这个实现依赖于消费者线程在通知队列它已经完成之前耗尽队列中的对象。不这样做将导致死锁。
Add
方法的工作原理是将一个对象的rvalue
引用移动到另一个线程中。一个rvalue
引用用于确保被发送到另一个线程的对象在被移动到队列后在当前线程中无效。这有助于防止数据竞争,其中生产者线程可能被留下一个有效的数据引用,该引用被发送到另一个线程。添加的每个对象都在队列的末尾,以便消费者可以按顺序消费对象。Consume
方法使用一个copy
操作从队列的开始拉出对象,然后从队列中移除原始对象。Swap
方法简单地切换m_Producer
和m_Consumer
指针;它在两个互斥体的保护下完成这项工作,因此当所有生产者和消费者线程都能够处理切换时,它可以确信切换正在发生。Swap
还将m_ConsumerIterator
设置到正确的队列,并向所有等待交换操作完成的线程发出一个notify
。
为了展示这个队列的作用,清单 11-17 中的例子使用了一个对象来维护一些算术运算的运行总数。main
函数充当生产者,将待完成的操作添加到队列中,并且创建一个线程来接收这些操作并执行它们。
清单 11-17 。一个可行的例子
#include <cassert>
#include <future>
#include <iostream>
#include <vector>
using namespace std;
class RunningTotal
{
private:
int m_Value{ 0 };
bool m_Finished{ false };
public:
RunningTotal& operator+=(int value)
{
m_Value += value;
return *this;
}
RunningTotal& operator-=(int value)
{
m_Value -= value;
return *this;
}
RunningTotal& Finish()
{
m_Finished = true;
return *this;
}
int operator *() const throw(int)
{
if (!m_Finished)
{
throw m_Value;
}
return m_Value;
}
};
template <typename T>
class MessageQueue
{
private:
using Queue = vector < T > ;
using QueueIterator = typename Queue::iterator;
Queue m_A;
Queue m_B;
Queue* m_Producer{ &m_A };
Queue* m_Consumer{ &m_B };
QueueIterator m_ConsumerIterator{ m_B.end() };
condition_variable& m_MessageCondition;
condition_variable m_ConsumptionFinished;
mutex m_MutexProducer;
mutex m_MutexConsumer;
unsigned int m_SwapCount{ 0 };
public:
MessageQueue(condition_variable& messageCondition)
: m_MessageCondition{ messageCondition }
{
}
unsigned int GetCount() const
{
return m_SwapCount;
}
void Add(T&& operation)
{
unique_lock<mutex> lock{ m_MutexProducer };
m_Producer->insert(m_Producer->end(), std::move(operation));
}
void BeginConsumption()
{
m_MutexConsumer.lock();
}
T Consume()
{
T operation;
if (m_Consumer->size() > 0)
{
operation = *m_ConsumerIterator;
m_ConsumerIterator = m_Consumer->erase(m_ConsumerIterator);
assert(m_ConsumerIterator == m_Consumer->begin());
}
return operation;
}
void EndConsumption()
{
assert(m_Consumer->size() == 0);
m_MutexConsumer.unlock();
m_ConsumptionFinished.notify_all();
}
unsigned int Swap()
{
unique_lock<mutex> lockB{ m_MutexConsumer };
m_ConsumptionFinished.wait(
lockB,
[this]()
{
return m_Consumer->size() == 0;
}
);
unique_lock<mutex> lockA{ m_MutexProducer };
Queue* temp{ m_Producer };
m_Producer = m_Consumer;
m_Consumer = temp;
m_ConsumerIterator = m_Consumer->begin();
m_MessageCondition.notify_all();
return m_SwapCount++;
}
};
using RunningTotalOperation = function < RunningTotal&() > ;
using RunningTotalMessageQueue = MessageQueue < RunningTotalOperation > ;
int Task(reference_wrapper<mutex> messageQueueMutex,
reference_wrapper<condition_variable> messageCondition,
reference_wrapper<RunningTotalMessageQueue> messageQueueRef)
{
int result{ 0 };
RunningTotalMessageQueue& messageQueue = messageQueueRef.get();
unsigned int currentSwapCount{ 0 };
bool finished{ false };
while (!finished)
{
unique_lock<mutex> lock{ messageQueueMutex.get() };
messageCondition.get().wait(
lock,
[&messageQueue, ¤tSwapCount]()
{
return currentSwapCount != messageQueue.GetCount();
}
);
messageQueue.BeginConsumption();
currentSwapCount = messageQueue.GetCount();
while (RunningTotalOperation operation{ messageQueue.Consume() })
{
RunningTotal& runningTotal = operation();
try
{
result = *runningTotal;
finished = true;
break;
}
catch (int param)
{
// nothing to do, not finished yet!
cout << "Total not yet finished, current is: " << param << endl;
}
}
messageQueue.EndConsumption();
}
return result;
}
int main(int argc, char* argv[])
{
RunningTotal runningTotal;
mutex messageQueueMutex;
condition_variable messageQueueCondition;
RunningTotalMessageQueue messageQueue(messageQueueCondition);
auto myFuture = async(launch::async,
Task,
ref(messageQueueMutex),
ref(messageQueueCondition),
ref(messageQueue));
messageQueue.Add(bind(&RunningTotal::operator+=, &runningTotal, 3));
messageQueue.Swap();
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 100));
messageQueue.Add(bind(&RunningTotal::operator+=, &runningTotal, 100000));
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 256));
messageQueue.Swap();
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 100));
messageQueue.Add(bind(&RunningTotal::operator+=, &runningTotal, 100000));
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 256));
messageQueue.Swap();
messageQueue.Add(bind(&RunningTotal::Finish, &runningTotal));
messageQueue.Swap();
cout << "The final total is: " << myFuture.get() << endl;
return 0;
}
这段代码代表了许多现代 C++ 语言特性的复杂使用。让我们将源代码分解成更小的例子来展示各个任务是如何在一个长时间运行的助手线程上执行的。清单 11-18 涵盖了RunningTotal
类。
清单 11-18 。RunningTotal Class
class RunningTotal
{
private:
int m_Value{ 0 };
bool m_Finished{ false };
public:
RunningTotal& operator+=(int value)
{
m_Value += value;
return *this;
}
RunningTotal& operator-=(int value)
{
m_Value -= value;
return *this;
}
RunningTotal& Finish()
{
m_Finished = true;
return *this;
}
int operator *() const throw(int)
{
if (!m_Finished)
{
throw m_Value;
}
return m_Value;
}
};
清单 11-18 中的RunningTotal
类是一个简单的对象,代表一个长期运行的数据存储。在适当的程序中,这个类可以是 web 服务器、数据库或呈现引擎的接口,它公开了更新其状态的方法。出于本例的目的,该类简单地包装了一个跟踪操作结果的int
和一个确定计算何时完成的bool
。使用被覆盖的+=
操作符、-=
操作符和*
操作符来操作这些值。还有一个Finished
方法将m_Finished
布尔值设置为true
。
main
函数负责实例化RunningTotal
对象以及消息队列和消费者线程。在清单 11-19 中可以看到。
清单 11-19 。main
功能
#include <future>
#include <iostream>
using namespace std;
using RunningTotalOperation = function < RunningTotal&() >;
using RunningTotalMessageQueue = MessageQueue < RunningTotalOperation > ;
int main(int argc, char* argv[])
{
RunningTotal runningTotal;
mutex messageQueueMutex;
condition_variable messageQueueCondition;
RunningTotalMessageQueue messageQueue(messageQueueCondition);
auto myFuture = async(launch::async,
Task,
ref(messageQueueMutex),
ref(messageQueueCondition),
ref(messageQueue));
messageQueue.Add(bind(&RunningTotal::operator+=, &runningTotal, 3));
messageQueue.Swap();
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 100));
messageQueue.Add(bind(&RunningTotal::operator+=, &runningTotal, 100000));
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 256));
messageQueue.Swap();
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 100));
messageQueue.Add(bind(&RunningTotal::operator+=, &runningTotal, 100000));
messageQueue.Add(bind(&RunningTotal::operator-=, &runningTotal, 256));
messageQueue.Swap();
messageQueue.Add(bind(&RunningTotal::Finish, &runningTotal));
messageQueue.Swap();
cout << "The final total is: " << myFuture.get() << endl;
return 0;
}
清单 11-19 中的第一段重要代码是main
之前的类型别名。这些用于创建表示您将使用的消息队列的类型以及消息队列包含的对象类型。在这种情况下,我创建了一个类型,您可以用它来对RunningTotal
类执行操作。这个类型别名是使用 C++ function
对象创建的,它允许你创建一个函数的表示,以便以后调用。这种类型要求您在模板中指定函数的签名类型——您可能会惊讶地发现签名是在没有参数的情况下描述的。这意味着存储在队列中的函子没有直接传递给它们的参数。这通常会导致+=
和-=
等需要参数的操作出现问题;但是bind
功能来帮忙了。你可以在main
函数中看到bind
的几种用法。所有这些bind
的例子都用来将一个方法指针绑定到该类型的一个方法实例。当使用方法指针时,传递给bind
的第二个参数应该总是调用该方法的对象的实例。执行仿函数时,任何后续参数都会自动传递给该函数。这种绑定参数的自动传递就是为什么您不需要在类型别名中指定任何参数类型,以及为什么您可以使用单个队列来表示具有不同签名的函数。
main
使用async
函数创建一个thread
,并将几个要在线程上执行的操作以及多个交换进行排队。例子的最后一段是Task
函数,在第二个线程上执行;见清单 11-20 。
清单 11-20 。Task
功能
#include <future>
#include <iostream>
using namespace std;
int Task(reference_wrapper<mutex> messageQueueMutex,
reference_wrapper<condition_variable> messageCondition,
reference_wrapper<RunningTotalMessageQueue> messageQueueRef)
{
int result{ 0 };
RunningTotalMessageQueue& messageQueue = messageQueueRef.get();
unsigned int currentSwapCount{ 0 };
bool finished{ false };
while (!finished)
{
unique_lock<mutex> lock{ messageQueueMutex.get() };
messageCondition.get().wait(
lock,
[&messageQueue, ¤tSwapCount]()
{
return currentSwapCount != messageQueue.GetCount();
}
);
messageQueue.BeginConsumption();
currentSwapCount = messageQueue.GetCount();
while (RunningTotalOperation operation{ messageQueue.Consume() })
{
RunningTotal& runningTotal = operation();
try
{
result = *runningTotal;
finished = true;
break;
}
catch (int param)
{
// nothing to do, not finished yet!
cout << "Total not yet finished, current is: " << param << endl;
}
}
messageQueue.EndConsumption();
}
return result;
}
Task
功能循环,直到finished bool
被设置为true
。在继续工作之前,它等待messageCondition condition_variable
发出信号,并使用 lambda 来确保在线程被操作系统而不是 notify 调用唤醒的情况下,交换确实发生了。
一旦线程被踢出,并且有工作要执行,它就调用队列上的BeginConsumption
方法。这具有锁定队列的Swap
方法的效果,直到线程中的所有当前作业都已完成。更新currentSwapCount
变量以确保condition_variable
在下次进入循环时能够保证安全。第二个while
循环负责从队列中取出每个函子,直到队列为空。这里是执行main
创建的绑定函数对象的地方。线程本身并不知道它正在执行的工作的实质;它只是响应已经在main
函数中排队的请求。
每次操作后使用* operator
来测试Finished
命令是否已发送。如果没有调用Finished
方法,RunningTotal::operator*
方法将抛出一个包含当前存储值的 int 异常。你可以看到这是如何在try...catch
模块的Task
功能中使用的。只有在operator*
返回值而不是抛出该值的情况下,才会执行result
变量、完成的bool
和break
语句。每次没有将操作标记为完成的操作完成时,当前总数都会打印到控制台。您可以在图 11-10 中看到该代码的结果。
图 11-10 。输出显示了正在运行的消息队列
十二、网络
通过互联网进行通信正日益成为许多现代计算机程序不可或缺的一部分。很难找到任何程序不连接到同一程序的另一个实例或为程序或应用程序的某个部分提供基本功能的 web 服务器。这为专门从事网络编程领域的开发人员创造了机会。当编写连接程序时,您可以采用几种不同的方法,使用高级库是一种有效的技术;然而,本章着眼于可以在 OS X、Linux 和 Windows 上使用的 Berkeley 套接字库。
Berkeley 套接字于 1983 年首次出现在 Unix 操作系统中。该操作系统在 20 世纪 80 年代末不再受版权问题的困扰,使得 Berkeley Sockets API 成为今天大多数操作系统上使用的标准实现。即使 Windows 不直接支持 Berkeley,但它的网络 API 几乎与 Berkeley 标准 API 完全相同。
这一章讲述了如何创建和使用套接字来编写可以通过网络(如互联网)相互通信的程序。配方 14-1、14-2 和 14-3 涵盖了当今使用的主要操作系统的相同材料。你应该阅读与你正在开发的系统相关的配方,然后继续阅读配方 12-4。
12-1.在 OS X 上设置 Berkeley Sockets 应用程序
问题
您想要创建一个可以在 OS X 上使用的网络套接字程序。
解决办法
OS X 将 Berkeley Sockets API 作为操作系统的一部分提供,无需借助外部库即可使用。
它是如何工作的
苹果提供了 Xcode IDE ,你可以用它从苹果电脑上构建 OS X 应用程序。Xcode 可以从 App Store 免费获得。安装后,您可以使用 Xcode 创建要在您选择的电脑上运行的程序。这个方法创建了一个命令行程序,它连接到互联网并打开一个到服务器的套接字。
首先,您必须为应用程序创建一个有效的项目。打开 Xcode,选择图 12-1 所示的新建 Xcode 项目选项。
图 12-1 。带有创建新 Xcode 项目选项的 Xcode 欢迎屏幕
系统会要求您选择希望创建的应用程序类型。选择 OS X 应用类别下的命令行工具选项;图 12-2 显示了这个窗口。
图 12-2 。OS X 应用程序命令行工具选项
接下来,要求您指定一个文件夹来存储您的项目文件。之后,Xcode 主窗口打开,您可以从左侧的项目视图中选择源文件。用清单 12-1 中的代码替换新 CPP 文件中的代码,创建一个打开 Google HTTP web 服务器套接字的应用程序。
清单 12-1 。打开伯克利插座
#include <iostream>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
using SOCKET = int;
using namespace std;
int main(int argc, const char * argv[])
{
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo *servinfo{};
getaddrinfo("www.google.com", "80", &hints, &servinfo);
SOCKET sockfd{
socket(servinfo->ai_family, servinfo->ai_socktype, servinfo->ai_protocol)
};
int connectionResult{ connect(sockfd, servinfo->ai_addr, servinfo->ai_addrlen) };
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
freeaddrinfo(servinfo);
return 0;
}
清单 12-1 中的代码需要一个关于互联网如何工作的简短入门,以便你完全理解正在发生的事情。在连接到服务器之前,您需要知道它所在的地址。最好使用域名服务(DNS) 找到它。DNS 的工作原理是保存给定主机名的服务器地址缓存。在本例中,您向 DNS 请求与www.google.com
相关联的地址。如果您正在创建一个在您自己的网络上运行的程序,您可以手动指定服务器的 IP 地址,但是对于使用互联网访问信息的程序来说,这通常是不可能的。服务器可以移动,IP 地址可以在不同的时间为不同的系统更改或重新使用。getaddrinfo
功能向 DNS 请求与端口 80 上的www.google.com
相关联的地址。
特定服务的服务器地址通常由两部分组成:要连接的计算机的 IP 地址和您希望与之通信的服务器上特定服务的端口。万维网使用 HTTP 协议进行通信,该协议通常配置为使用端口 80 提供数据。您可以在清单 12-1 中看到,这是您试图在远程计算机上建立连接的端口。
getaddrinfo
函数将网址、端口和两个addrinfo struct
作为参数。这些struct
中的第一个为 DNS 服务提供了一些提示,关于你想与远程计算机建立的连接类型。此时最重要的两个是ai_family
和ai_socktype
字段。
ai_family
字段指定您想要为您的程序检索的地址类型。这允许您指定是需要 IPv4、IPv6、NetBIOS、红外线还是蓝牙地址。清单 12-1 中提供的选项是未指定的,它允许getaddrinfo
函数返回所请求网址的所有有效 IP 地址。这些有效的 IP 地址由相同的addrinfo struct
表示,并通过提供给getaddrinfo
第四个参数的指针传回程序。
ai_socktype
字段允许您指定与相关插座一起使用的传输机制的类型。清单 12-1 中的SOCK_STREAM
选项创建了一个使用 TCP/IP 作为传输机制的套接字。这种类型的套接字允许您发送保证按顺序到达目的地的信息包。本章中使用的另一种传动机构是SOCK_DGRAM
型。这种传输机制不保证数据包会到达,也不保证它们会按预期的顺序到达;然而,它们没有 TCP/IP 机制带来的开销,因此可以在计算机之间以更低的延迟发送数据包。
由getaddrinfo
函数返回的servinfo
可以用来创建一个套接字。从socket
函数中获得一个套接字文件描述符,该函数从servinfo
结构中传递信息。在这个例子中,servinfo
结构可以是一个链表,因为 Google 支持 IPv4 和 IPv6 地址格式。您可以在这里编写代码来选择要使用的地址并适当地操作。只要列表中有更多的元素,字段ai_next
就存储指向列表中下一个元素的指针。ai_family
、ai_socktype
和ai_protocol
变量都被传递到socket
函数中,以创建一个有效的套接字来使用。一旦有了有效的套接字,就可以调用connect
函数。connect
函数获取套接字 ID、来自包含地址的servinfo
对象的ai_addr
字段和ai_addrlen
来确定地址的长度。如果连接没有成功,您将从connect
收到一个返回值-1。清单 12-1 显示了连接是否成功。
12-2.在 Ubuntu 上的 Eclipse 中设置 Berkeley Sockets 应用程序
问题
您想使用 Eclipse 创建一个可以在 Ubuntu 上使用的网络套接字程序。
解决办法
Ubuntu 提供了 Berkeley Sockets API 作为操作系统的一部分,可以在不借助外部库的情况下使用。
它是如何工作的
Eclipse IDE 可用于在运行 Linux 的计算机上构建应用程序。Eclipse 可以从 Ubuntu 软件中心免费获得。一旦安装完毕,您就可以使用 Eclipse 创建在您选择的计算机上运行的程序。这个方法创建了一个命令行程序,它连接到互联网并打开一个到服务器的套接字。
首先,您必须为应用程序创建一个有效的项目。打开 Eclipse,从菜单栏中选择项目 New 选项。新建项目向导打开,如图图 12-3 所示。
图 12-3 。Eclipse 新项目向导
新建项目向导允许您选择 C++ 项目作为选项。然后,点击下一步,你会看到如图 12-4 所示的 C++ 项目设置窗口。
图 12-4 。Eclipse C++ 项目设置窗口
在此窗口中,您可以给项目命名,并决定应该在哪个文件夹中创建项目。在项目类型下,选择可执行文件 Hello World C++ 项目。这样做将创建一个项目,该项目被配置为构建为可执行文件,并且包含一个用于添加您自己的代码的源文件。
本章中的示例代码使用了 C++11 语言规范中的功能。默认的 Eclipse 项目没有启用这一功能。您可以通过右键单击您的项目并选择 Properties 来打开它。你应该会看到图 12-5 中所示的设置窗口,左边是类别。要启用 C++11 支持,选择 C/C++ Build 下的设置,将–std=c++11
添加到 All Options 字段,然后单击 OK。
图 12-5 。向您的 Eclipse 项目添加 C++11 支持
用清单 12-2 中的代码替换新 CPP 文件中的代码,创建一个打开 Google HTTP web 服务器套接字的应用程序。
注以下代码和描述与配方 12-1 完全相同。如果你已经读过这份材料,你可能希望跳到食谱 12-4。如果你跳过了食谱 12-1,因为 OS X 与你无关,那么继续读下去。
清单 12-2 。打开伯克利插座
#include <iostream>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
using SOCKET = int;
using namespace std;
int main(int argc, const char * argv[])
{
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
addrinfo *servinfo{};
getaddrinfo("www.google.com", "80", &hints, &servinfo);
SOCKET sockfd{
socket(servinfo->ai_family, servinfo->ai_socktype, servinfo->ai_protocol)
};
int connectionResult{ connect(sockfd, servinfo->ai_addr, servinfo->ai_addrlen) };
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
freeaddrinfo(servinfo);
return 0;
}
清单 12-2 中的代码需要一个关于互联网如何工作的简短入门,以便你完全理解正在发生的事情。在连接到服务器之前,您需要知道它所在的地址。最好使用域名服务(DNS)找到它。DNS 的工作原理是保存给定主机名的服务器地址缓存。在本例中,您向 DNS 请求与www.google.com
相关联的地址。如果你正在创建一个在你自己的网络上运行的程序,你可以手动指定服务器的 IP 地址,但是这对于通过互联网访问信息的程序来说通常是不可能的。服务器可以移动,IP 地址可以在不同的时间为不同的系统更改或重新使用。getaddrinfo
功能向 DNS 请求与端口 80 上的www.google.com
相关的地址。
特定服务的服务器地址通常由两部分组成:要连接的计算机的 IP 地址和您希望与之通信的服务器上特定服务的端口。万维网使用 HTTP 协议进行通信,该协议通常配置为使用端口 80 提供数据。您可以在清单 12-2 中看到,这是您试图在远程计算机上建立连接的端口。
getaddrinfo
函数将网址、端口和两个addrinfo struct
作为参数。这些struct
中的第一个为 DNS 服务提供了一些提示,关于你想与远程计算机建立的连接类型。此时最重要的两个是ai_family
和ai_socktype
字段。
ai_family
字段指定您想要为您的程序检索的地址类型。这允许您指定是否需要 IPv4、IPv6、NetBIOS、红外线或蓝牙地址。在清单 12-2 中提供的选项是未指定的,它允许getaddrinfo
函数为请求的 web 地址返回所有有效的 IP 地址。这些有效的 IP 地址由相同的addrinfo struct
表示,并通过提供给getaddrinfo
第四个参数的指针传回程序。
ai_socktype
字段允许您指定与相关插座一起使用的传输机制的类型。清单 12-2 中的SOCK_STREAM
选项创建了一个使用 TCP/IP 作为传输机制的套接字。这种类型的套接字允许您发送保证按顺序到达目的地的信息包。本章中使用的另一种传动机构是SOCK_DGRAM
型。这种传输机制不保证数据包会到达,也不保证它们会按预期的顺序到达;然而,它们没有 TCP/IP 机制带来的开销,因此可以在计算机之间以更低的延迟发送数据包。
由getaddrinfo
函数返回的servinfo
可以用来创建一个套接字。从socket
函数中获得一个套接字文件描述符,该函数从servinfo
结构中传递信息。在这个例子中,servinfo
结构可以是一个链表,因为 Google 支持 IPv4 和 IPv6 地址格式。您可以在这里编写代码来选择要使用的地址并适当地操作。只要列表中有更多的元素,字段ai_next
就存储指向列表中下一个元素的指针。ai_family
、ai_socktype
和ai_protocol
变量都被传递到socket
函数中,以创建一个有效的套接字来使用。一旦有了有效的套接字,就可以调用connect
函数。connect
函数获取套接字 ID、来自包含地址的servinfo
对象的ai_addr
字段和ai_addrlen
来确定地址的长度。如果连接没有成功,您将从connect
收到一个返回值-1。清单 12-2 显示了连接是否成功。
12-3.在 Windows 上的 Visual Studio 中设置 Winsock 2 应用程序
问题
您想要创建一个可以在 Windows 机器上使用的网络套接字程序。
解决办法
微软提供了 Winsock 库,它支持计算机之间基于套接字的通信。
它是如何工作的
Windows 操作系统没有像 OS X 或 Ubuntu 那样自带本地的 Berkeley 套接字实现。相反,微软提供了 Winsock 库。幸运的是,这个库与 Berkeley Sockets 库非常相似,在某种程度上,大部分代码可以在三个平台之间互换。通过打开 Visual Studio 并选择 File New
项目选项,可以创建一个使用 Winsock 的新 C++ 应用程序。这样做将打开新项目向导,如图图 12-6 所示。
图 12-6 。Visual Studio 新建项目向导
您希望创建一个 Win32 控制台应用程序来运行本章中的示例代码。选择这种类型的应用程序,输入名称,并选择存储数据的文件夹。然后单击确定。
您将进入 Win32 应用程序向导。点击下一步,进入图 12-7 中所示的对话框。
图 12-7 。Win32 应用程序向导
取消选择预编译头和安全开发生命周期(SDL) 检查选项,然后单击完成。当你这样做的时候,你会看到一个工作项目。不过,该项目不支持套接字,因为 Windows 要求您链接一个库来提供套接字支持。您可以通过在“解决方案资源管理器”窗口中右击项目并选择“属性”来实现这一点。在配置属性链接器
输入部分指定要链接的库。图 12-8 显示了选择了特定选项的窗口。
图 12-8 。Visual Studio 链接器输入选项
您希望向附加依赖项部分添加一个新库。选择该选项,点击向下箭头,打开如图图 12-9 所示的对话框。
图 12-9 。附加依赖对话框
Winsock API 由Ws2_32.lib
静态库提供。在文本框中输入该值,然后点击确定。这允许您在程序中毫无问题地使用 Winsock 2 API。
用清单 12-3 中的代码替换新 CPP 文件中的代码,创建一个打开 Google HTTP web 服务器套接字的应用程序。
注以下代码和描述与配方 12-1 中的大部分相同。但是,有些部分是 Windows 独有的。如果你已经阅读了这份材料,你可能希望涵盖 Windows 特有的方面,然后跳到食谱 12-4。如果您跳过了配方 12-1 和配方 12-2,请继续阅读。
清单 12-3 。打开 Winsock 套接字
#include <iostream>
#include <winsock2.h>
#include <WS2tcpip.h>
using namespace std;
int main(int argc, char* argv[])
{
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
return 1;
}
addrinfo hints{};
hints.ai_family = AF_UNSPEC; // don't care IPv4 or IPv6
hints.ai_socktype = SOCK_STREAM; // TCP stream sockets
// get ready to connect
addrinfo* servinfo{}; // will point to the results
getaddrinfo("www.google.com", "80", &hints, &servinfo);
SOCKET sockfd{ socket(servinfo->ai_family, servinfo->ai_socktype, servinfo->ai_protocol) };
int connectionResult{ connect(sockfd, servinfo->ai_addr, servinfo->ai_addrlen) };
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
freeaddrinfo(servinfo);
WSACleanup();
return 0;
}
在清单 12-3 中用粗体显示的代码部分是 Windows socket 库独有的,不能转移到 Unix 和 OS X 的 Berkeley Sockets 实现中。Windows 要求您的程序启动并关闭 Winsock 库。这是通过使用WSAStartup
和WSACleanup
功能实现的。另一个细微的区别是,Winsock API 将SOCKET
类型指定为unsigned int
。OS X 和 Ubuntu 中的 Berkeley 实现都从socket
函数返回一个标准的int
。清单 12-1 和清单 12-2 中的代码使用类型别名来指定SOCKET
类型,使代码看起来更具可移植性;然而,平台之间的类型仍然不同。
为了让你完全理解正在发生的事情,这个代码需要一个关于互联网如何工作的简短入门。在连接到服务器之前,您需要知道它所在的地址。最好使用域名服务(DNS)找到它。DNS 的工作原理是保存给定主机名的服务器地址缓存。在这个例子中,您向 DNS 请求与www.google.com
相关联的地址。如果您正在创建一个在您自己的网络上运行的程序,您可以手动指定服务器的 IP 地址,但是对于使用互联网访问信息的程序来说,这通常是不可能的。服务器可以移动,IP 地址可以在不同的时间为不同的系统更改或重新使用。getaddrinfo
功能向 DNS 请求与端口 80 上的www.google.com
相关的地址。
特定服务的服务器地址通常由两部分组成:要连接的计算机的 IP 地址和您希望与之通信的服务器上特定服务的端口。万维网使用 HTTP 协议进行通信,该协议通常配置为使用端口 80 提供数据。您可以在清单 12-3 中看到,这是您试图在远程计算机上建立连接的端口。
getaddrinfo
函数将网址、端口和两个addrinfo struct
作为参数。这些struct
中的第一个为 DNS 服务提供了一些提示,关于你想与远程计算机建立的连接类型。此时最重要的两个是ai_family
和ai_socktype
字段。
ai_family
字段指定您想要为您的程序检索的地址类型。这允许您指定是需要 IPv4、IPv6、NetBIOS、红外线还是蓝牙地址。清单 12-3 中提供的选项是未指定的,它允许getaddrinfo
函数返回所请求网址的所有有效 IP 地址。这些有效的 IP 地址由相同的addrinfo struct
表示,并通过提供给getaddrinfo
第四个参数的指针传回程序。
ai_socktype
字段允许您指定与相关插座一起使用的传输机制的类型。清单 12-3 中的SOCK_STREAM
选项创建了一个使用 TCP/IP 作为传输机制的套接字。这种类型的套接字允许您发送保证按顺序到达目的地的信息包。本章中使用的另一种传动机构是SOCK_DGRAM
型。这种传输机制不保证数据包会到达,也不保证它们会按预期的顺序到达;然而,它们没有 TCP/IP 机制带来的开销,因此可以在计算机之间以更低的延迟发送数据包。
由getaddrinfo
函数返回的servinfo
可以用来创建一个套接字。从socket
函数中获得一个套接字文件描述符,该函数从servinfo
结构中传递信息。在这个例子中,servinfo
结构可以是一个链表,因为 Google 支持 IPv4 和 IPv6 地址格式。您可以在这里编写代码来选择要使用的地址并适当地操作。只要列表中有更多的元素,字段ai_next
就存储指向列表中下一个元素的指针。ai_family
、ai_socktype
和ai_protocol
变量都被传递到socket
函数中,以创建一个有效的套接字来使用。一旦有了有效的套接字,就可以调用connect
函数。connect
函数获取套接字 ID、来自包含地址的servinfo
对象的ai_addr
字段和ai_addrlen
来确定地址的长度。如果连接没有成功,您将从connect
收到一个返回值-1。清单 12-3 显示了连接是否成功。
12-4.在两个程序之间创建套接字连接
问题
你想写一个网络客户端程序和一个服务器程序,可以通过网络进行通信。
解决办法
您可以使用 Berkeley Sockets API 通过套接字发送和接收数据。
它是如何工作的
Berkeley 套接字被设计成通过网络发送和接收信息。API 提供了send
和recv
函数来实现这个目标。实现这一点的困难在于,您必须确保为数据传输正确配置您的套接字。设置套接字时,接收数据所需的操作与发送数据所需的操作非常不同。该方法还创建了可以在多种平台上运行的代码,并使用 Microsoft Visual Studio、Xcode 或在 Linux 机器上使用 Clang 作为编译器进行编译。
注意
Socket
类在使用 GCC 时不会编译,因为编译器还不支持stringstream
类的move
构造函数。如果您使用 GCC,您可以修改示例代码以避免用stringstream
调用move
。
当程序构建为在 Windows 机器上运行时,要查看的第一个类启动和停止 Winsock。当您在 OS X 或 Linux 计算机上构建和运行时,这个类不应该有任何影响。清单 12-4 显示了这是如何实现的。
清单 12-4 。包装 Winsock
#include <iostream>
using namespace std;
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#include <WinSock2.h>
#include <WS2tcpip.h>
#define UsingWinsock 1
using ssize_t = SSIZE_T;
#else
#define UsingWinsock 0
#endif
class WinsockWrapper
{
public:
WinsockWrapper()
{
#if UsingWinsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
exit(1);
}
#ifndef NDEBUG
cout << "Winsock started!" << endl;
#endif
#endif
}
~WinsockWrapper()
{
#if UsingWinsock
WSACleanup();
#ifndef NDEBUG
cout << "Winsock shut down!" << endl;
#endif
#endif
}
};
int main(int argc, char* argv[])
{
WinsockWrapper myWinsockWrapper;
return 0;
}
清单 12-4 中的代码使用预处理器检测微软 Visual Studio 的存在。Visual Studio 在构建时定义了符号 _MSC_VER 。您可以在用 Visual Studio 构建 Windows 程序时使用它来包含特定于 Windows 的文件,就像我在这里所做的那样。仅当使用 Visual Studio 生成时,Winsock 2 库才使用 pragma 包含在此程序中;必要的 Winsock 头文件也包括在内。设置了一个专门用于该程序的define
。在 Visual Studio 中构建代码时,UsingWinsock
宏被定义为 1;当代码不是使用 Visual Studio 构建时,它被设置为 0。Windows 构建还要求您创建一个类型别名来将SSIZE_T
映射到ssize_t
,因为当不在 Windows 计算机上构建时,该类型使用小写拼写。
WinsockWrapper
类在其构造函数和析构函数中检测UsingWinsock
的值。如果该值为 1,则启动和停止 Winsock API 的函数在。当不使用 Visual Studio 构建时,不编译此代码;因此以这种方式包含是安全的。
main
函数在其第一行创建一个WinsockWrapper
对象。这将导致在 Windows 计算机上调用构造函数并初始化 Winsock 它对非 Windows 版本没有影响。当该对象超出范围时,Winsock API 也会关闭,因为会调用析构函数。现在,您有了一种方便的方法,可以跨多个平台移植来启动和停止 Winsock。
类是从一个程序到另一个程序通信的组成部分。它负责为基于 C 的 Berkeley Sockets API 提供面向对象的包装。socket
本身由一个描述符表示,这个描述符本质上是一个int
。一种方法创建一个类,该类将创建 Berkeley 套接字所需的数据与处理套接字所需的代码相关联。Socket
类的完整源代码显示在清单 12-5 中。
清单 12-5 。创建面向对象的Socket Class
class Socket
{
private:
#if !UsingWinsock
using SOCKET = int;
#endif
addrinfo* m_ServerInfo{ nullptr };
SOCKET m_Socket{ static_cast<SOCKET>(0xFFFFFFFF) };
sockaddr_storage m_AcceptedSocketStorage{};
socklen_t m_AcceptedSocketSize{ sizeof(m_AcceptedSocketStorage) };
void CreateSocket(string& webAddress, string& port, addrinfo& hints)
{
getaddrinfo(webAddress.c_str(), port.c_str(), &hints, &m_ServerInfo);
m_Socket = socket(
*m_ServerInfo->ai_family,*
*m_ServerInfo->ai_socktype,*
m_ServerInfo->ai_protocol);
}
Socket(int newSocket, sockaddr_storage&& socketStorage)
: m_Socket{ newSocket }
, m_AcceptedSocketStorage(move(socketStorage))
{
}
public:
Socket(string& port)
{
#ifndef NDEBUG
stringstream portStream{ port };
int portValue{};
portStream >> portValue;
assert(portValue > 1024); // Ports under 1024 are reserved for certain applications and protocols!
#endif
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
string address{ "" };
CreateSocket(address, port, hints);
}
Socket(string& webAddress, string& port)
{
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
CreateSocket(webAddress, port, hints);
}
Socket(string& webAddress, string& port, addrinfo& hints)
{
CreateSocket(webAddress, port, hints);
}
~Socket()
{
Close();
}
bool IsValid()
{
return m_Socket != -1;
}
int Connect()
{
*int connectionResult{*
connect(m_Socket, m_ServerInfo->ai_*addr, m_ServerInfo->ai_addrlen)*
};
#ifndef NDEBUG
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
#endif
return connectionResult;
}
int Bind()
{
int bindResult{ ::bind(m_Socket, m_ServerInfo->ai_addr, m_ServerInfo->ai_addrlen) };
#ifndef NDEBUG
if (bindResult == -1)
{
cout << "Bind Failed!" << endl;
}
else
{
cout << "Bind Successful" << endl;
}
#endif
return bindResult;
}
int Listen(int queueSize)
{
int listenResult{ listen(m_Socket, queueSize) };
#ifndef NDEBUG
if (listenResult == -1)
{
cout << "Listen Failed" << endl;
}
else
{
cout << "Listen Succeeded" << endl;
}
#endif
return listenResult;
}
Socket Accept()
{
SOCKET newSocket{
*accept(m_Socket,*
reinterpret_cast<sockad*dr*>(*&*m_AcceptedSocketStorage),*
&*m_AcceptedSocketSize)*
};
#ifndef NDEBUG
if (newSocket == -1)
{
cout << "Accept Failed" << endl;
}
else
{
cout << "Accept Succeeded" << endl;
}
#endif
m_AcceptedSocketSize = sizeof(m_AcceptedSocketStorage);
return Socket(newSocket, move(m_AcceptedSocketStorage));
}
void Close()
{
#ifdef _MSC_VER
closesocket(m_Socket);
#else
close(m_Socket);
#endif
m_Socket = -1;
freeaddrinfo(m_ServerInfo);
}
ssize_t Send(stringstream data)
{
string packetData{ data.str() };
ssize_t sendResult{ send(m_Socket, packetData.c_str(), packetData.length(), 0) };
#ifndef NDEBUG
if (sendResult == -1)
{
cout << "Send Failed" << endl;
}
else
{
cout << "Send Succeeded" << endl;
}
#endif
return sendResult;
}
stringstream Receive()
{
const int size{ 1024 };
char dataReceived[size];
ssize_t receiveResult{ recv(m_Socket, dataReceived, size, 0) };
#ifndef NDEBUG
if (receiveResult == -1)
{
cout << "Receive Failed" << endl;
}
else if (receiveResult == 0)
{
cout << "Receive Detected Closed Connection!" << endl;
Close();
}
else
{
dataReceived[receiveResult] = '\0';
cout << "Receive Succeeded" << endl;
}
#endif
stringstream data{ dataReceived };
return move(data);
}
};
Socket class
有三个不同的构造函数,允许你为不同的目的创建套接字。第一个公共构造函数只接受一个port
作为参数。这种构造方法适用于用于监听传入连接的Socket
对象。构造函数中的hints addrinfo struct
将ai_flags
参数设置为AI_PASSIVE
值,并为address
传递一个空的string
。这告诉getaddrinfo
函数填写本地计算机的 IP 地址作为套接字使用的地址。以这种方式使用本地地址可以让您打开套接字来监听计算机—当您希望从外部源接收程序中的数据时,这是一项重要的任务。
第二个公共构造函数接受一个地址和一个端口。这允许您创建一个Socket
,它自动使用 IPv6 或 IPv4 和 TCP/IP 来创建一个可用于发送数据的套接字。第一个和第二个构造函数都是为了方便起见——它们都可以被删除,以支持第三个公共构造函数,它接受一个地址、一个端口和一个addrinfo struct
,并允许用户按照自己的意愿配置一个Socket
。
最后一个构造函数是私有构造函数。当外部程序连接到套接字侦听连接时,使用这种类型的构造函数。您可以看到这是如何在Accept
方法中使用的。
IsValid
方法确定Socket
是否已经用适当的描述符初始化。CreateSocket
中的socket
函数在失败的结果中返回-1;m_Socket
的默认值也是-1。
当你希望建立一个到远程计算机的连接,并且对接收来自其他程序的连接不感兴趣时,可以使用Connect
方法。它主要用于客户端-服务器关系的客户端;然而,不难想象,您可以编写使用不同套接字监听和连接他人的对等程序。Connect
调用 Berkeley connect
函数,但是能够从对象中使用m_Socket
和m_ServerInfo
对象,而不必从外部位置手动传递它们。
当您希望接收输入连接时,使用Bind
方法。Berkeley bind
函数负责协商访问您希望与操作系统一起使用的端口。操作系统负责发送和接收网络流量,端口用于让计算机知道哪个程序正在哪个端口上等待数据。当using namespace std;
语句存在时,bind
函数上的scope
操作符对于该代码是必需的。这告诉编译器从全局名称空间而不是从std
名称空间使用bind
方法。来自std
名称空间的bind
方法用于创建仿函数,与套接字无关。
Listen
方法出现在对Bind
的调用之后,它告诉套接字开始对来自远程机器的连接进行排队。queueSize
参数指定队列的大小;一旦队列满了,操作系统就会丢弃连接。您的操作系统可以支持的连接数量会有所不同。桌面操作系统通常支持比服务器专用操作系统少得多的排队连接。大多数情况下,5 这样的数字就可以了。
Accept
方法从调用Listen
时创建的队列中提取连接。Accept
调用 Berkeley accept
函数,该函数将m_Socket
变量作为其第一个参数。第二个和第三个参数是m_AcceptedSocketStorage
和m_AcceptedSocketSize
变量。m_AcceptedSocketStorage
成员变量属于sockaddr_storage
类型,而不是accept
方法所期望的sockaddr
类型。sockaddr_storage
类型足够大,可以处理 IPv4 和 IPv6 地址,但是accept
方法仍然需要一个指向sockaddr
类型的指针。这并不理想;但是,可以使用reinterpret_cast
来解决这个问题,因为 accept 也会考虑被传递对象的大小。如果返回的对象小于传入的大小,则改变大小;因此,在函数返回之前,大小被重置。将m_AcceptedSocketStorage
对象移动到从函数返回的新的Socket
对象中,以确保初始Socket
中的副本无效。
Close
方法负责在不再需要Socket
时关闭它。在 Windows 上调用closesocket
函数,在非 Windows 平台上使用close
函数。freeaddrinfo
对象也在该类的析构函数中被释放。
接下来的方法是Send
。不出所料,这个方法将数据发送到连接另一端的机器。Send
被设置为发送一个stringstream
对象,因为正确序列化二进制数据超出了本书的范围。您可以看到,调用send
Berkeley 函数时使用了m_Socket
描述符以及从传入的stringstream
对象中提取的字符串数据和大小。
Receive
方法负责从远程连接引入数据。这个调用会一直阻塞,直到准备好从套接字连接读取数据。Receive
函数可以返回三种类型的值:-1(当遇到错误时),0(当连接被远程计算机关闭时),或者一个表示接收到的字节数的正值。接收到的数据被读入一个char
数组,然后传递给一个stringstream
对象,该对象将使用move
构造函数从函数中返回。
现在你已经有了一个全功能的Socket
类,你可以创建程序来发送和接收数据。清单 12-6 中的代码可以用来创建一个等待远程连接和单个接收消息的程序。
清单 12-6 。创建一个可以接收数据的程序
#include <cassert>
#include <iostream>
#include <type_traits>
#include <vector>
#ifndef NDEBUG
#include <sstream>
#endif
using namespace std;
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#include <WinSock2.h>
#include <WS2tcpip.h>
#define UsingWinsock 1
using ssize_t = SSIZE_T;
#else
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#define UsingWinsock 0
#endif
class WinsockWrapper
{
public:
WinsockWrapper()
{
#if UsingWinsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
exit(1);
}
cout << "Winsock started!" << endl;
#endif
}
~WinsockWrapper()
{
#if UsingWinsock
WSACleanup();
cout << "Winsock shut down!" << endl;
#endif
}
};
class Socket
{
private:
#if !UsingWinsock
using SOCKET = int;
#endif
addrinfo* m_ServerInfo{ nullptr };
SOCKET m_Socket{ static_cast<SOCKET>(0xFFFFFFFF) };
sockaddr_storage m_AcceptedSocketStorage{};
socklen_t m_AcceptedSocketSize{ sizeof(m_AcceptedSocketStorage) };
void CreateSocket(string& webAddress, string& port, addrinfo& hints)
{
getaddrinfo(webAddress.c_str(), port.c_str(), &hints, &m_ServerInfo);
m_Socket = socket(
m_ServerInfo->ai_family,
m_ServerInfo->ai_socktype,
m_ServerInfo->ai_protocol);
}
Socket(int newSocket, sockaddr_storage&& socketStorage)
: m_Socket{ newSocket }
, m_AcceptedSocketStorage(move(socketStorage))
{
}
public:
Socket(string& port)
{
#ifndef NDEBUG
stringstream portStream{ port };
int portValue{};
portStream >> portValue;
assert(portValue > 1024);
// Ports under 1024 are reserved for certain applications and protocols!
#endif
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
string address{ "" };
CreateSocket(address, port, hints);
}
Socket(string& webAddress, string& port)
{
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
CreateSocket(webAddress, port, hints);
}
Socket(string& webAddress, string& port, addrinfo& hints)
{
CreateSocket(webAddress, port, hints);
}
~Socket()
{
Close();
}
bool IsValid()
{
return m_Socket != -1;
}
int Connect()
{
int connectionResult{
connect(m_Socket, m_ServerInfo->ai_addr, m_ServerInfo->ai_addrlen)
};
#ifndef NDEBUG
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
#endif
return connectionResult;
}
int Bind()
{
int bindResult{ ::bind(m_Socket, m_ServerInfo->ai_addr, m_ServerInfo->ai_addrlen) };
#ifndef NDEBUG
if (bindResult == -1)
{
cout << "Bind Failed!" << endl;
}
else
{
cout << "Bind Successful" << endl;
}
#endif
return bindResult;
}
int Listen(int queueSize)
{
int listenResult{ listen(m_Socket, queueSize) };
#ifndef NDEBUG
if (listenResult == -1)
{
cout << "Listen Failed" << endl;
}
else
{
cout << "Listen Succeeded" << endl;
}
#endif
return listenResult;
}
Socket Accept()
{
SOCKET newSocket{
accept(m_Socket,
reinterpret_cast<sockaddr*>(&m_AcceptedSocketStorage),
&m_AcceptedSocketSize)
};
#ifndef NDEBUG
if (newSocket == -1)
{
cout << "Accept Failed" << endl;
}
else
{
cout << "Accept Succeeded" << endl;
}
#endif
m_AcceptedSocketSize = sizeof(m_AcceptedSocketStorage);
return Socket(newSocket, move(m_AcceptedSocketStorage));
}
void Close()
{
#ifdef _MSC_VER
closesocket(m_Socket);
#else
close(m_Socket);
#endif
m_Socket = -1;
freeaddrinfo(m_ServerInfo);
}
ssize_t Send(stringstream data)
{
string packetData{ data.str() };
ssize_t sendResult{ send(m_Socket, packetData.c_str(), packetData.length(), 0) };
#ifndef NDEBUG
if (sendResult == -1)
{
cout << "Send Failed" << endl;
}
else
{
cout << "Send Succeeded" << endl;
}
#endif
return sendResult;
}
stringstream Receive()
{
const int size{ 1024 };
char dataReceived[size];
ssize_t receiveResult{ recv(m_Socket, dataReceived, size, 0) };
#ifndef NDEBUG
if (receiveResult == -1)
{
cout << "Receive Failed" << endl;
}
else if (receiveResult == 0)
{
cout << "Receive Detected Closed Connection!" << endl;
Close();
}
else
{
dataReceived[receiveResult] = '\0';
cout << "Receive Succeeded" << endl;
}
#endif
stringstream data{ dataReceived };
return move(data);
}
};
int main(int argc, char* argv[])
{
WinsockWrapper myWinsockWrapper;
string port{ "3000" };
Socket myBindingSocket(port);
myBindingSocket.Bind();
int listenResult{ myBindingSocket.Listen(5) };
assert(listenResult != -1);
Socket acceptResult{ myBindingSocket.Accept() };
assert(acceptResult.IsValid());
stringstream data{ acceptResult.Receive() };
string message;
getline(data, message, '\0');
cout << "Received Message: " << message << endl;
return 0;
}
清单 12-6 中的代码创建了一个程序,该程序有一个套接字,它等待从远程连接接收一条消息。由于封装在WinsockWrapper
和Socket
类中的困难工作,main
函数最终只包含几行代码。如果运行在 Visual Studio for Windows 计算机构建的服务器上,main
函数首先创建一个WinsockWrapper
来初始化 Winsock。然后用一个空地址将一个Socket
初始化到端口 3000。此端口将用于侦听本地计算机上的连接。你可以看到是这样的,因为main
函数接着调用Bind
,然后调用队列大小为 5 的Listen
,最后才调用Accept
。Accept
调用会阻塞,直到队列中出现一个远程连接。Accept
返回一个用于接收数据的单独的Socket
对象。对那个Socket
的Receive
调用也是一个阻塞调用,程序在那里等待,直到数据可用。在返回之前,程序通过打印出接收到的消息来结束。
一旦构建并运行了服务器程序,就需要一个客户机程序来连接它并发送消息。这显示在清单 12-7 中。
清单 12-7 。客户端程序
#include <cassert>
#include <iostream>
#include <type_traits>
#ifndef NDEBUG
#include <sstream>
#endif
using namespace std;
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#include <WinSock2.h>
#include <WS2tcpip.h>
#define UsingWinsock 1
using ssize_t = SSIZE_T;
#else
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#define UsingWinsock 0
#endif
class WinsockWrapper
{
public:
WinsockWrapper()
{
#if UsingWinsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
exit(1);
}
#ifndef NDEBUG
cout << "Winsock started!" << endl;
#endif
#endif
}
~WinsockWrapper()
{
#if UsingWinsock
WSACleanup();
#ifndef NDEBUG
cout << "Winsock shut down!" << endl;
#endif
#endif
}
};
class Socket
{
private:
#if !UsingWinsock
using SOCKET = int;
#endif
addrinfo* m_ServerInfo{ nullptr };
SOCKET m_Socket{ static_cast<SOCKET>(0xFFFFFFFF) };
sockaddr_storage m_AcceptedSocketStorage{};
socklen_t m_AcceptedSocketSize{ sizeof(m_AcceptedSocketStorage) };
void CreateSocket(string& webAddress, string& port, addrinfo& hints)
{
getaddrinfo(webAddress.c_str(), port.c_str(), &hints, &m_ServerInfo);
m_Socket = socket(m_ServerInfo->ai_family,
m_ServerInfo->ai_socktype,
m_ServerInfo->ai_protocol);
}
Socket(int newSocket, sockaddr_storage&& socketStorage)
: m_Socket{ newSocket }
, m_AcceptedSocketStorage(move(socketStorage))
{
}
public:
Socket(string& port)
{
#ifndef NDEBUG
stringstream portStream{ port };
int portValue{};
portStream >> portValue;
assert(portValue > 1024);
// Ports under 1024 are reserved for certain applications and protocols!
#endif
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
string address{ "" };
CreateSocket(address, port, hints);
}
Socket(string& webAddress, string& port)
{
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
CreateSocket(webAddress, port, hints);
}
Socket(string& webAddress, string& port, addrinfo& hints)
{
CreateSocket(webAddress, port, hints);
}
~Socket()
{
Close();
}
bool IsValid()
{
return m_Socket != -1;
}
int Connect()
{
int connectionResult{ connect(
m_Socket,
m_ServerInfo->ai_addr,
m_ServerInfo->ai_addrlen)
};
#ifndef NDEBUG
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
#endif
return connectionResult;
}
int Bind()
{
int bindResult{ ::bind(m_Socket, m_ServerInfo->ai_addr, m_ServerInfo->ai_addrlen) };
#ifndef NDEBUG
if (bindResult == -1)
{
cout << "Bind Failed!" << endl;
}
else
{
cout << "Bind Successful" << endl;
}
#endif
return bindResult;
}
int Listen(int queueSize)
{
int listenResult{ listen(m_Socket, queueSize) };
#ifndef NDEBUG
if (listenResult == -1)
{
cout << "Listen Failed" << endl;
}
else
{
cout << "Listen Succeeded" << endl;
}
#endif
return listenResult;
}
Socket Accept()
{
SOCKET newSocket{ accept(m_Socket, reinterpret_cast<sockaddr*>(&m_AcceptedSocketStorage), &m_AcceptedSocketSize) };
#ifndef NDEBUG
if (newSocket == -1)
{
cout << "Accept Failed" << endl;
}
else
{
cout << "Accept Succeeded" << endl;
}
#endif
m_AcceptedSocketSize = sizeof(m_AcceptedSocketStorage);
return Socket(newSocket, move(m_AcceptedSocketStorage));
}
void Close()
{
#ifdef _MSC_VER
closesocket(m_Socket);
#else
close(m_Socket);
#endif
m_Socket = -1;
freeaddrinfo(m_ServerInfo);
}
ssize_t Send(stringstream data)
{
string packetData{ data.str() };
ssize_t sendResult{ send(m_Socket, packetData.c_str(), packetData.length(), 0) };
#ifndef NDEBUG
if (sendResult == -1)
{
cout << "Send Failed" << endl;
}
else
{
cout << "Send Succeeded" << endl;
}
#endif
return sendResult;
}
stringstream Receive()
{
const int size{ 1024 };
char dataReceived[size];
ssize_t receiveResult{ recv(m_Socket, dataReceived, size, 0) };
#ifndef NDEBUG
if (receiveResult == -1)
{
cout << "Receive Failed" << endl;
}
else if (receiveResult == 0)
{
cout << "Receive Detected Closed Connection!" << endl;
Close();
}
else
{
dataReceived[receiveResult] = '\0';
cout << "Receive Succeeded" << endl;
}
#endif
stringstream data{ dataReceived };
return move(data);
}
};
int main(int argc, char* argv[])
{
WinsockWrapper myWinsockWrapper;
string address("192.168.178.44");
string port("3000");
Socket myConnectingSocket(address, port);
myConnectingSocket.Connect();
string message("Sending Data Over a Network!");
stringstream data;
data << message;
myConnectingSocket.Send(move(data));
return 0;
}
清单 12-7 显示了相同的Socket
类可以在服务器和客户机上使用。客户端的main
函数也使用WinsockWrapper
对象来处理 Winsock 库的启动和关闭。然后创建一个连接到 IP 地址 192.168.178.44 的Socket
。(这是我用来托管服务器程序的计算机的地址。)在创建了Socket
之后,调用Connect
方法,以在不同计算机上运行的两个程序之间建立连接。Send
方法是最后一个函数调用,发送字符串“通过网络发送数据!”图 12-10 显示了在 MacBook Pro 上运行服务器和在 Windows 8.1 桌面 PC 上运行客户端所获得的输出。
图 12-10 。在 OS X 上运行服务器生成的输出
12-5.在两个程序之间创建网络协议
问题
您希望创建两个能够遵循标准模式相互通信的程序。
解决办法
您可以创建一个两个程序都可以遵循的协议,这样每个程序都知道如何响应给定的请求。
它是如何工作的
在两个程序之间建立的套接字连接可以用来双向发送数据:从发起连接的程序到接收者,也可以从接收者返回到发起者。这个特性允许您编写能够响应请求的网络应用程序,甚至可以构建需要在单个应用程序中来回发送多条消息的更复杂的协议。
您可能熟悉的当今使用的最常见的协议示例是 HTTP。HTTP 是支持万维网的网络协议。它是一个请求和响应协议,让客户端程序从服务器请求数据。当浏览器向服务器请求网页时,可以看到常见的应用程序,但移动应用程序使用 HTTP 在其应用程序和服务器后端之间传输数据也并不罕见。其他常见的协议有 FTP(用于促进计算机之间的文件传输)以及 POP 和 SMTP 电子邮件协议。
这个菜谱展示了一个非常简单的网络协议,它向服务器提出一个问题,让客户机用一个答案进行响应,并让服务器告诉客户机答案是否正确。与 HTTP 这样复杂的例子相比,这个协议微不足道,但是它是一个很好的起点。
该协议由四条消息组成:QUESTION
、ANSWER
、QUIT
和FINISHED
。当用户应该被询问一个问题时,QUESTION
消息从客户端发送到服务器。服务器通过向客户端发送一个问题来响应此消息。客户端通过向服务器发送ANSWER
以及用户的回答来响应问题。客户端可以在任何时候向服务器发送QUIT
来终止套接字连接。一旦服务器将所有问题发送到服务器,来自客户端的后续QUESTION
请求将导致FINISHED
被发送到客户端;那么连接将被终止。
这个方法中的服务器程序可以同时处理多个客户端连接。它通过使用Socket::Accept
方法接受单个连接,然后使用async
函数将连接到客户端的Socket
交给一个thread
来实现这一点。你可以在清单 12-8 的中看到服务器程序的源代码。
清单 12-8 。协议服务器程序
#include <array>
#include <cassert>
#include <future>
#include <iostream>
#include <thread>
#include <type_traits>
#include <vector>
#ifndef NDEBUG
#include <sstream>
#endif
using namespace std;
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#include <WinSock2.h>
#include <WS2tcpip.h>
#define UsingWinsock 1
using ssize_t = SSIZE_T;
#else
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#define UsingWinsock 0
#endif
class WinsockWrapper
{
public:
WinsockWrapper()
{
#if UsingWinsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
exit(1);
}
cout << "Winsock started!" << endl;
#endif
}
~WinsockWrapper()
{
#if UsingWinsock
WSACleanup();
cout << "Winsock shut down!" << endl;
#endif
}
};
class Socket
{
private:
#if !UsingWinsock
using SOCKET = int;
#endif
addrinfo* m_ServerInfo{ nullptr };
SOCKET m_Socket{ static_cast<SOCKET>(0xFFFFFFFF) };
sockaddr_storage m_AcceptedSocketStorage{};
socklen_t m_AcceptedSocketSize{ sizeof(m_AcceptedSocketStorage) };
void CreateSocket(string& webAddress, string& port, addrinfo& hints)
{
getaddrinfo(webAddress.c_str(), port.c_str(), &hints, &m_ServerInfo);
m_Socket = socket(m_ServerInfo->ai_family,
m_ServerInfo->ai_socktype,
m_ServerInfo->ai_protocol);
}
Socket(int newSocket, sockaddr_storage&& socketStorage)
: m_Socket{ newSocket }
, m_AcceptedSocketStorage(move(socketStorage))
{
}
public:
Socket(string& port)
{
#ifndef NDEBUG
stringstream portStream{ port };
int portValue{};
portStream >> portValue;
assert(portValue > 1024);
// Ports under 1024 are reserved for certain applications and protocols!
#endif
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
`string address{ "" };`
`CreateSocket(address, port, hints);`
`}`
`Socket(string& webAddress, string& port)`
`{`
`addrinfo hints{};`
`hints.ai_family = AF_UNSPEC;`
`hints.ai_socktype = SOCK_STREAM;`
`CreateSocket(webAddress, port, hints);`
`}`
`Socket(string& webAddress, string& port, addrinfo& hints)`
`{`
`CreateSocket(webAddress, port, hints);`
`}`
`~Socket()`
`{`
`Close();`
`}`
`Socket(const Socket& other) = delete;`
`Socket(Socket&& other)`
`: m_ServerInfo( other.m_ServerInfo )`
`, m_Socket( other.m_Socket )`
`, m_AcceptedSocketStorage( other.m_AcceptedSocketStorage )`
`, m_AcceptedSocketSize( other.m_AcceptedSocketSize )`
`{`
`if (this != &other)`
`{`
`other.m_ServerInfo = nullptr;`
`other.m_Socket = -1;`
`other.m_AcceptedSocketStorage = sockaddr_storage{};`
`other.m_AcceptedSocketSize = sizeof(other.m_AcceptedSocketStorage);`
`}`
`}`
`bool IsValid()`
`{`
`return m_Socket != -1;`
`}`
`int Connect()`
`{`
`int connectionResult{`
`connect(m_Socket,`
`m_ServerInfo->ai_addr,`
`m_ServerInfo->ai_addrlen)`
`};`
`#ifndef NDEBUG`
`if (connectionResult == -1)`
`{`
`cout << "Connection failed!" << endl;`
`}`
`else`
`{`
`cout << "Connection successful!" << endl;`
`}`
`#endif`
`return connectionResult;`
`}`
`int Bind()`
`{`
`int bindResult{ ::bind(m_Socket, m_ServerInfo->ai_addr, m_ServerInfo->ai_addrlen) };`
`#ifndef NDEBUG`
`if (bindResult == -1)`
`{`
`cout << "Bind Failed!" << endl;`
`}`
`else`
`{`
`cout << "Bind Successful" << endl;`
`}`
`#endif`
`return bindResult;`
`}`
`int Listen(int queueSize)`
`{`
`int listenResult{ listen(m_Socket, queueSize) };`
`#ifndef NDEBUG`
`if (listenResult == -1)`
`{`
`cout << "Listen Failed" << endl;`
`}`
`else`
`{`
`cout << "Listen Succeeded" << endl;`
`}`
`#endif`
`return listenResult;`
`}`
`Socket Accept()`
`{`
`SOCKET newSocket{`
`accept(m_Socket,`
`reinterpret_cast<sockaddr*>(&m_AcceptedSocketStorage),`
`&m_AcceptedSocketSize)`
`};`
`#ifndef NDEBUG`
`if (newSocket == -1)`
`{`
`cout << "Accept Failed" << endl;`
`}`
`else`
`{`
`cout << "Accept Succeeded" << endl;`
`}`
`#endif`
`m_AcceptedSocketSize = sizeof(m_AcceptedSocketStorage);`
`return Socket(newSocket, move(m_AcceptedSocketStorage));`
`}`
`void Close()`
`{`
`#ifdef _MSC_VER`
`closesocket(m_Socket);`
`#else`
`close(m_Socket);`
`#endif`
`m_Socket = -1;`
`freeaddrinfo(m_ServerInfo);`
`}`
`ssize_t Send(stringstream data)`
`{`
`string packetData{ data.str() };`
`ssize_t sendResult{ send(m_Socket, packetData.c_str(), packetData.length(), 0) };`
`#ifndef NDEBUG`
`if (sendResult == -1)`
`{`
`cout << "Send Failed" << endl;`
`}`
`else`
`{`
`cout << "Send Succeeded" << endl;`
`}`
`#endif`
`return sendResult;`
`}`
`stringstream Receive()`
`{`
`const int size{ 1024 };`
`char dataReceived[size];`
`ssize_t receiveResult{ recv(m_Socket, dataReceived, size, 0) };`
`#ifndef NDEBUG`
`if (receiveResult == -1)`
`{`
`cout << "Receive Failed" << endl;`
`}`
`else if (receiveResult == 0)`
`{`
`cout << "Receive Detected Closed Connection!" << endl;`
`Close();`
`}`
`else`
`{`
`dataReceived[receiveResult] = '\0';`
`cout << "Receive Succeeded" << endl;`
`}`
`#endif`
`stringstream data{ dataReceived };`
`return move(data);`
`}`
`};`
`namespace`
`{`
`const int NUM_QUESTIONS{ 2 };`
`const array<string, NUM_QUESTIONS> QUESTIONS`
`{`
`"What is the capital of Australia?",`
`"What is the capital of the USA?"`
`};`
`const array<string, NUM_QUESTIONS> ANSWERS{ "Canberra", "Washington DC" };`
`}`
`bool ProtocolThread(reference_wrapper<Socket> connectionSocketRef)`
`{`
`Socket socket{ move(connectionSocketRef.get()) };`
`int currentQuestion{ 0 };`
`string message;`
`while (message != "QUIT")`
`{`
`stringstream sstream{ socket.Receive() };`
`if (sstream.rdbuf()->in_avail() == 0)`
`{`
`break;`
`}`
`sstream >> message;`
`stringstream output;`
`if (message == "QUESTION")`
`{`
`if (currentQuestion >= NUM_QUESTIONS)`
`{`
`output << "FINISHED";`
`socket.Send(move(output));`
`cout << "Quiz Complete!" << endl;`
`break;`
`}`
`output << QUESTIONS[currentQuestion];`
`}`
`else if (message == "ANSWER")`
`{`
`string answer;`
`sstream >> answer;`
`if (answer == ANSWERS[currentQuestion])`
`{`
`output << "You are correct!";`
`}`
`else`
`{`
`output << "Sorry the correct answer is " << ANSWERS[currentQuestion];`
`}`
`++currentQuestion;`
`}`
`socket.Send(move(output));`
`}`
`return true;`
`}`
`int main(int argc, char* argv[])`
`{`
`WinsockWrapper myWinsockWrapper;`
`string port("3000");`
`Socket myListeningSocket(port);`
`int bindResult{ myListeningSocket.Bind() };`
`assert(bindResult != -1);`
`if (bindResult != -1)`
`{`
`int listenResult{ myListeningSocket.Listen(5) };`
`assert(listenResult != -1);`
`if (listenResult != -1)`
`{`
`while (true)`
`{`
`Socket acceptedSocket{ myListeningSocket.Accept() };`
`async(launch::async, ProtocolThread, ref(acceptedSocket));`
`}`
`}`
`}`
`return 0;`
`}`
清单 12-8 中的服务器程序使用的
Socket类与配方 12-4 中详细描述的相同。
main函数负责同时处理多个客户端。它通过创建一个
Socket并将其绑定到端口 3000 来实现这一点。然后,要求绑定的
Socket监听传入的连接;它使用长度为 5 的队列来实现。
main的最后一部分使用一个
while循环来接受任何传入的连接,并将它们交给
async函数。
async函数创建一个
thread来处理从
Socket::Accept获取的每个
Socket;第一个参数是
launch::async`。
ProtocolThread
功能响应连接客户端的请求,并支持简单问答网络协议的服务器端。通过将字符串打包到每个数据包中,在客户端和服务器之间传输数据。message
变量保存来自stringstream
的单个消息。这个协议可以用一个基本的if...else if
模块来处理。当收到QUESTION
消息时,服务器将当前问题打包成输出stringstream
。如果消息是ANSWER
,那么服务器检查用户是否正确,并将适当的响应打包到输出中。使用最初接收数据的同一个Socket
将输出stringstream
发送到客户端,这表明Socket
连接不一定是单向通信通道。如果接收到QUESTION
消息,并且已经发送了服务器可用的所有问题,则服务器向客户端发送FINISHED
消息,并退出循环;这导致Socket
超出范围,进而关闭连接。
所有这些活动都需要连接一个客户机来与服务器程序通信。你可以在清单 12-9 的中看到一个基本的客户端实现。
清单 12-9 。一个简单的测验协议客户端
#include <cassert>
#include <iostream>
#include <type_traits>
#ifndef NDEBUG
#include <sstream>
#endif
using namespace std;
#ifdef _MSC_VER
#pragma comment(lib, "Ws2_32.lib")
#include <WinSock2.h>
#include <WS2tcpip.h>
#define UsingWinsock 1
using ssize_t = SSIZE_T;
#else
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#define UsingWinsock 0
#endif
class WinsockWrapper
{
public:
WinsockWrapper()
{
#if UsingWinsock
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
{
exit(1);
}
cout << "Winsock started!" << endl;
#endif
}
~WinsockWrapper()
{
#if UsingWinsock
WSACleanup();
cout << "Winsock shut down!" << endl;
#endif
}
};
class Socket
{
private:
#if !UsingWinsock
using SOCKET = int;
#endif
addrinfo* m_ServerInfo{ nullptr };
SOCKET m_Socket{ static_cast<SOCKET>(0xFFFFFFFF) };
sockaddr_storage m_AcceptedSocketStorage{};
socklen_t m_AcceptedSocketSize{ sizeof(m_AcceptedSocketStorage) };
void CreateSocket(string& webAddress, string& port, addrinfo& hints)
{
getaddrinfo(webAddress.c_str(), port.c_str(), &hints, &m_ServerInfo);
m_Socket = socket(
m_ServerInfo->ai_family,
m_ServerInfo->ai_socktype,
m_ServerInfo->ai_protocol);
}
Socket(int newSocket, sockaddr_storage&& socketStorage)
: m_Socket{ newSocket }
, m_AcceptedSocketStorage(move(socketStorage))
{
}
public:
Socket(string& port)
{
#ifndef NDEBUG
stringstream portStream{ port };
int portValue{};
portStream >> portValue;
assert(portValue > 1024);
// Ports under 1024 are reserved for certain applications and protocols!
#endif
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
string address{ "" };
CreateSocket(address, port, hints);
}
Socket(string& webAddress, string& port)
{
addrinfo hints{};
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
CreateSocket(webAddress, port, hints);
}
Socket(string& webAddress, string& port, addrinfo& hints)
{
CreateSocket(webAddress, port, hints);
}
~Socket()
{
Close();
}
Socket(const Socket& other) = delete;
Socket(Socket&& other)
: m_ServerInfo(other.m_ServerInfo)
, m_Socket(other.m_Socket)
, m_AcceptedSocketStorage(other.m_AcceptedSocketStorage)
, m_AcceptedSocketSize(other.m_AcceptedSocketSize)
{
if (this != &other)
{
other.m_ServerInfo = nullptr;
other.m_Socket = -1;
other.m_AcceptedSocketStorage = sockaddr_storage{};
other.m_AcceptedSocketSize = sizeof(other.m_AcceptedSocketStorage);
}
}
bool IsValid()
{
return m_Socket != -1;
}
int Connect()
{
int connectionResult{ connect(
m_Socket,
m_ServerInfo->ai_addr,
m_ServerInfo->ai_addrlen)
};
#ifndef NDEBUG
if (connectionResult == -1)
{
cout << "Connection failed!" << endl;
}
else
{
cout << "Connection successful!" << endl;
}
#endif
return connectionResult;
}
int Bind()
{
int bindResult{ ::bind(m_Socket, m_ServerInfo->ai_addr, m_ServerInfo->ai_addrlen) };
#ifndef NDEBUG
if (bindResult == -1)
{
cout << "Bind Failed!" << endl;
}
else
{
cout << "Bind Successful" << endl;
}
#endif
return bindResult;
}
int Listen(int queueSize)
{
int listenResult{ listen(m_Socket, queueSize) };
#ifndef NDEBUG
if (listenResult == -1)
{
cout << "Listen Failed" << endl;
}
else
{
cout << "Listen Succeeded" << endl;
}
#endif
return listenResult;
}
Socket Accept()
{
SOCKET newSocket{ accept(
m_Socket,
reinterpret_cast<sockaddr*>(&m_AcceptedSocketStorage),
&m_AcceptedSocketSize)
};
#ifndef NDEBUG
if (newSocket == -1)
{
cout << "Accept Failed" << endl;
}
else
{
cout << "Accept Succeeded" << endl;
}
#endif
m_AcceptedSocketSize = sizeof(m_AcceptedSocketStorage);
return Socket(newSocket, move(m_AcceptedSocketStorage));
}
void Close()
{
#ifdef _MSC_VER
closesocket(m_Socket);
#else
close(m_Socket);
#endif
m_Socket = -1;
freeaddrinfo(m_ServerInfo);
}
ssize_t Send(stringstream data)
{
string packetData{ data.str() };
ssize_t sendResult{ send(m_Socket, packetData.c_str(), packetData.length(), 0) };
#ifndef NDEBUG
if (sendResult == -1)
{
cout << "Send Failed" << endl;
}
else
{
cout << "Send Succeeded" << endl;
}
#endif
return sendResult;
}
stringstream Receive()
{
const int size{ 1024 };
char dataReceived[size];
ssize_t receiveResult{ recv(m_Socket, dataReceived, size, 0) };
#ifndef NDEBUG
if (receiveResult == -1)
{
cout << "Receive Failed" << endl;
}
else if (receiveResult == 0)
{
cout << "Receive Detected Closed Connection!" << endl;
Close();
}
else
{
dataReceived[receiveResult] = '\0';
cout << "Receive Succeeded" << endl;
}
#endif
stringstream data{ dataReceived };
return move(data);
}
};
int main(int argc, char* argv[])
{
WinsockWrapper myWinsockWrapper;
string address("192.168.178.44");
string port("3000");
Socket mySocket(address, port);
int connectionResult{ mySocket.Connect() };
if (connectionResult != -1)
{
stringstream output{ "QUESTION" };
mySocket.Send(move(output));
stringstream input{ mySocket.Receive() };
if (input.rdbuf()->in_avail() > 0)
{
string question;
getline(input, question, '\0');
input.clear();
while (question != "FINISHED")
{
cout << question << endl;
string answer;
cin >> answer;
output << "ANSWER ";
output << answer;
mySocket.Send(move(output));
input = mySocket.Receive();
if (input.rdbuf()->in_avail() == 0)
{
break;
}
string result;
getline(input, result, '\0');
cout << result << endl;
output << "QUESTION";
mySocket.Send(move(output));
input = mySocket.Receive();
getline(input, question, '\0');
input.clear();
}
}
}
return 0;
}
清单 12-9 中的客户端程序可以连接到清单 12-8 中的服务器,并向玩家展示服务器测验。客户端代码比服务器简单,因为它只需要考虑一个连接,因此不需要线程或处理多个套接字。客户端不需要知道要连接的服务器的地址;IP 地址是我在家庭网络上运行服务器的 MacBook Pro 的 IP 地址。客户端将QUESTION
发送给服务器,然后在Receive
调用中等待响应。Receive
是阻塞呼叫;因此,客户端会等待,直到数据可用。然后,它从玩家那里获得输入并发送回服务器,并等待关于用户是否正确的响应。这个过程循环重复,直到服务器通知客户端测验已经结束。
以这种方式实现的网络协议的美妙之处在于它们可以在不同的程序中重用。如果您想扩展这个例子,您可以使用 Qt 之类的框架轻松地创建一个 GUI 版本,让所有对Receive
的调用都发生在一个线程中,并让 UI 动画化一个旋转的徽标,以向用户表明程序正在等待数据通过远程连接。您还可以扩展服务器应用程序来存储结果,并添加到协议中,让用户重新开始正在进行的测验。最后,该协议简单地规定了两个程序应该如何相互通信,以便于从一台计算机向另一台计算机提供服务。`
十三、脚本
C++ 是一种功能强大的编程语言,可以以多种方式使用,并支持几种不同的编程范例。它允许高级面向对象的抽象和通用编程,但它也允许您在考虑 CPU 特性(如缓存行的长度)的非常低的级别进行编码。这种能力是以需要将语言编译成机器代码为代价的。编译、构建和链接 C++ 是程序员需要承担的一项任务,对于非程序员来说并不容易理解。
脚本语言有助于降低对程序进行与代码相关的更改的障碍,并使艺术和设计团队能够控制高级任务。像屏幕布局和 UI 流这样的东西用脚本语言编写并不少见,这样团队中的非编程成员就可以很容易地修改它们。有几种流行的脚本语言可用,其中之一是 Lua 。本章着眼于 Lua 编程语言与 C++ 的不同之处,以及如何将 Lua 解释器和引擎整合到 C++ 程序中。
13-1.在 Visual Studio 中创建 Lua 库项目
问题
您希望使用 Visual Studio 编写一个包含 Lua 脚本语言的程序。
解决办法
Lua 编程语言提供了制作一个有效的 Lua 程序所需的所有源文件。您可以将这些文件包含到一个单独的 Visual Studio 项目中,该项目可用于生成静态库。
它是如何工作的
Visual Studio 程序可以由几个组成部分组成。Visual Studio 通过为包含多个项目的应用程序创建解决方案文件来支持这一点。Visual Studio 中的项目可以配置为创建 EXE、静态库或动态库。对于这个菜谱,您将创建一个包含两个项目的解决方案:一个构建包含 Lua 库的静态库,另一个创建静态链接到 Lua 项目并在其代码中使用 Lua 的应用程序。按照以下步骤创建一个项目,该项目构建一个链接到 Lua C 库的应用程序:
-
打开 Visual Studio,从“开始”屏幕或“文件”菜单中选择创建新项目的选项。
-
单击“已安装的模板”下的“Visual C++”类别,并为新应用程序选择 Win32 项目模板。
-
Give your project a name, choose a location to store its files, and click OK to proceed. Figure 13-1 shows the New Project Wizard.
图 13-1 。步骤 3 中的 Visual Studio 新建项目向导
-
在应用程序向导中,选择控制台应用程序,并取消选中预编译头和安全开发生命周期(SDL)选项。
-
单击完成。
-
在 Solution Explorer 窗口中右键单击新创建的解决方案,并选择 Add
New Project。
-
再次选择 Win32 项目,将项目命名为 Lua,然后单击“确定”。
-
在应用程序向导中单击下一步,然后选择静态库选项。
-
取消选中预编译头选项和 SDL 选项,然后单击完成。
-
从
www.lua.org
下载 Lua 源代码。 -
使用 7-Zip 之类的应用程序解压下载的
tar.gz
文件,并将src
文件夹复制到您用来创建 Lua 项目的文件夹中。 -
在 Visual Studio 解决方案资源管理器窗口中,右键单击 Lua 项目中的源文件文件夹,并选择 Add
Existing Item。
-
从您复制到项目目录的
src
Lua 文件夹中添加以下文件:
*lapi.c
*lauxlib.c
*lbaselib.c
*lbitlib.c
*lcode.c
*lcorolib.c
*lctype.c
*ldblib.c
*ldebug.c
*ldo.c
*ldump.c
*lfunc.c
*lgc.c
*linit.c
*liolib.c
*llex.c
*lmathlib.c
*lmem.c
*loadlib.c
*lobject.c
*lopcodes.c
*loslib.
c
*lparser.c
*lstate.c
*lstring.c
*lstrlib.
c
*ltable.c
*ltablib.c
*ltm.c
*lundump.c
*lutf8lib.c
*lvm.c
*lzio.c
-
右键单击您的 Lua 项目,然后单击 Build,可以看到生成的
Lua.lib
文件没有错误。 -
右键单击第十三章项目,并选择 Properties。
-
展开“通用属性”部分,然后单击“引用”。
-
单击添加新引用。
-
检查 Lua 项目,并选择 OK。
-
展开配置属性下的 C/C++ 部分,然后单击常规。
-
确保配置选项设置为所有配置。
-
编辑附加的包含目录选项,这样它就有了复制到 Lua 项目文件夹中的 Lua 源文件夹的路径。
-
Replace your
main
function source in the project CPP file with the code from Listing 13-1.
***清单 13-1*** 。一个简单的 Lua 程序
```cpp
#include "lua.hpp"
int main(int argc, char* argv[])
{
lua_State* pLuaState{ luaL_newstate() };
if (pLuaState)
{
luaL_openlibs(pLuaState);
lua_close(pLuaState);
}
return 0;
}
```
- 构建并执行您的程序,查看 Lua 静态库是否成功包含在您的项目中。
按照这些步骤,您可以创建一个 Lua 静态库项目,您可以在本章余下的食谱中使用它。
13-2.在 Eclipse 中创建 Lua 库项目
问题
您希望使用 Lua 作为脚本语言创建一个 C++ 程序,并且您正在安装了 Eclipse 的 Linux 计算机上进行开发。
解决办法
Lua 作为源代码提供,您可以创建一个 Eclipse 项目,该项目可以构建到一个静态库中,以包含在其他程序中。
它是如何工作的
Eclipse IDE 允许您创建可以链接到应用程序项目的新静态库项目。按照以下步骤创建一个 Eclipse 项目,为您的 Lua 项目构建一个 Linux 兼容的静态库:
- 打开您的 Eclipse IDE,并导航到 C/C++ 透视图。
- 在项目浏览器窗口中右键单击,并选择 New
C++ Project。
- 展开“静态库”类别,并选择“空项目”。
- 为项目命名,并选取一个文件夹来存储项目。
- 单击完成。
- 在 Project Explorer 窗口中右键单击您的新项目,并选择 New
Source Folder。给它起个名字。
- 从
www.lua.org
下载 Lua 源代码。 - 解压您获得的
tar.gz
文件,并将.c
和.h
文件从src
文件夹复制到您新创建的项目源文件夹中。 - 在“项目资源管理器”窗口中右键单击您的项目,然后选择“刷新”。
- 注意到 Lua 源文件和头文件出现在 Project Explorer 窗口中。
- 右键单击项目,并选择 Build 以确保源代码正确编译。
- 右键单击项目浏览器窗口中的空白区域,并选择 New
C++ Project。
- 选择可执行的
Hello World C++ 项目。
- 设置项目名称字段。
- 选择一个位置。
- 单击完成。
- 在“项目资源管理器”窗口中右键单击新的可执行项目,并选择“属性”。
- 单击 C/C++ Build 类别,并确保将 Configuration 设置为 Debug。
- 展开 C/C++ Build 类别,然后单击 Settings。
- 选择 GCC C++ 链接器类别下的库选项。
- 在 Libraries 部分点击 Add 选项,输入 Lua (不需要输入liblua . a——lib 和。零件是自动添加的)。
- 单击库搜索路径选项上的添加选项。
- 单击工作区。
- 选择 Lua 项目中的 Debug 文件夹。
- 对发布配置重复步骤 18–24(在生成发布文件夹和库之前,您需要在发布配置中构建 Lua 项目)。
- 在 C/C++ Build
设置对话框中选择 GCC C++ 编译器
Includes 部分。
- 将配置设置为所有配置。
- 单击“包含路径”部分中的“添加”选项。
- 单击工作区按钮。
- 选择您在步骤 6 中添加到 Lua 项目中的源文件夹。
- 在 C/C++ Build
Settings 部分的 GCC C++ 编译器设置下选择 Miscellaneous 部分。
- 将
–std=c++11
添加到其他标志字段。 - Replace your
main
function with the source code in Listing 13-2.
***清单 13-2*** 。一个简单的 Lua 程序
```cpp
#include "lua.hpp"
int main()
{
luaState* pLuaState{ luaL_newstate() };
if (pLuaState)
{
luaL_openlibs(pLuaState);
lua_close(pLuaState);
}
return 0;
}
```
- 调试你的应用程序,并逐步确保清单 13-2 中的变量是有效的,并且一切按预期完成。
这个方法中的步骤允许您在 Eclipse 中创建一个 Lua 静态库项目,您现在可以在本章的剩余部分中使用它。
13-3.在 Xcode 中创建 Lua 项目
问题
您想在 Xcode 中创建一个使用 Lua 编程语言编写脚本的 C++ 程序。
解决办法
您可以在 Xcode 中创建项目,这些项目允许您生成要链接到 C++ 应用程序中的静态库。
它是如何工作的
Xcode IDE 允许您创建可以构建可执行文件或库的项目。这个菜谱向您展示了如何配置一个项目,将 Lua 源代码构建为一个静态库,并将它链接到另一个生成可执行文件的项目中。按照以下步骤设置您的项目:
- 打开 Xcode。
- 选择“创建新的 Xcode 项目”。
- 在 OS X 框架和库部分选择库选项。
- 单击下一步。
- 将产品名称设置为 Lua。
- 将框架更改为无。
- 将类型更改为静态。
- 选择一个文件夹来储存 Xcode 资源库项目。
- 从
www.lua.org
下载 Lua 源代码。 - 解压从网页上获取的
tar,gz
文件。 - 将源文件从
src
文件夹复制到步骤 8 中创建的 Lua 项目文件夹。 - 在 Xcode 中右键单击项目,并选择 Add Files to Lua。
- 关闭 Xcode。
- 打开 Xcode。
- 选择“创建新的 Xcode 项目”。
- 从 OS X 应用程序部分选择命令行工具选项。
- 设置产品名称字段。
- 取消选中使用故事板选项。
- 单击下一步。
- 选择一个文件夹来存储项目。
- 打开 Finder,浏览到包含 Lua 项目的文件夹。
- 将
xcodeproj
文件拖到 Xcode 窗口的app
项目中。现在你应该在app
项目下有了 Lua 项目。 - 点击
app
项目,然后点击 Build Phases 选项。 - 展开将二进制文件与库链接选项。
- 单击加号。
- 从工作区部分选择
libLua.a
。 - 单击构建设置。
- 双击标题搜索路径选项。
- 单击加号,并在您的 Lua 项目中输入 Lua 源代码的路径。
- Replace the code in
AppDelegate.m
with the code in Listing 13-3.
***清单 13-3*** 。一个简单的 Lua 程序
```cpp
#import "AppDelegate.h"
#include "lua.hpp"
@property (weak) IBOutlet NSWindow *window;
@end
@implementation AppDelegate
- (void)applicationDidFinishLaunching:(NSNotofication *)aNotification {
lua_State* pLuaState{ luaL_newstate() };
if (pLuaState)
{
luaL_openlibs(pLuaState);
lua_close(pLuaState);
}
}
- (void)applicationWillTerminate:(NSNotification *)aNotification {
}
@end
```
- 构建并调试您的程序,使用断点来确保 Lua 状态被正确初始化。
本章提供的步骤和代码是使用 Xcode 6.1.1 生成的。您可能需要修改本章中剩余的例子,用applicationDidFinishLaunching
Objective-C 方法替换main
函数。如果您的程序无法编译,请尝试在标识和类型设置中将源文件的类型从 Objective-C 更改为 Objective-C++。
13-4.使用 Lua 编程语言
问题
您是一名 C++ 程序员,希望在将 Lua 编程语言添加到您自己的应用程序之前先学习一下。
解决办法
Lua 编程语言文档可在/www.lua.org/manual/5.3
获得,测试代码的现场演示可在www.lua.org/demo.html
获得。
它是如何工作的
Lua 编程语言与 C++ 几乎完全不同。C++ 是一种直接在 CPU 上执行的编译语言。另一方面,Lua 是一种解释型语言,由运行在 CPU 上的虚拟机执行。Lua 语言附带了一个用 C 编写的虚拟机,并提供了源代码。这意味着您可以将虚拟机嵌入到您编写的任何 C 或 C++ 程序中,并使用脚本语言来编写和控制应用程序的高级功能。
在承担这样的任务之前,学习 Lua 编程语言的一些特性是一个好主意。
使用变量
C++ 变量是静态类型的。这意味着它们的类型是在声明变量时指定的,并且在将来的任何时候都不能更改。一个int
在其整个生命周期内保持为一个int
。这有助于使 C++ 程序在 CPU 上运行时具有可预测性和高性能,因为正确的指令可以用于正确类型的变量。另一方面,Lua 代码运行在虚拟机中;因此,对变量可以表示的类型限制较少。这导致 Lua 被称为动态类型语言。清单 13-4 显示了动态类型对正在执行的程序的影响。
清单 13-4 。使用 Lua 变量
variable = 1
print(variable)
print(type(variable))
variable = "1"
print(variable)
print(type(variable))
你可以复制清单 13-4 中的代码,直接粘贴到www.lua.org/demo.html
的 Lua Live 演示中。这个演示有运行、清除、恢复和重启 Lua 虚拟机的控件。粘贴或键入清单 13-4 中的代码后,点击运行,网页会生成以下输出:
1
number
1
string
这个输出让您看到动态类型的作用。清单 13-4 最初给variable
分配一个整数值。这由print
函数输出,作为日志中的数字 1。type
函数在被调用时返回一个代表变量类型的字符串。对type
的第一次调用返回number
作为变量的类型。然后将值“1”的字符串表示分配给variable
。print
函数以与整数值相同的方式表示字符串值 1。没有办法判断变量中当前存储的是哪种类型。对type
函数的第二次调用表明该值实际上是一个字符串,而不再是一个数字。
如果你不小心的话,动态类型语言会让你的程序发生有趣的事情。在 C++ 中,除非重载赋值操作符来处理这种特殊情况,否则无法给字符串添加数字。Lua 可以轻松处理这样的操作。清单 13-5 展示了这一点。
清单 13-5 。向字符串中添加数字
variable = 1
print(variable)
print(type(variable))
variable = "1"
print(variable)
print(type(variable))
variable = variable + 1
print(variable)
print(type(variable))
清单 13-5 给原本显示在清单 13-4 中的代码增加了一个额外的操作。该操作将值 1 加到variable
上。回想一下前面的输出,在variable
中的值最后是由一个字符串表示的。以下输出显示了执行清单 13-5 后发生的情况:
1
number
1
string
2.0
number
该变量现在保存一个由浮点值 2.0 表示的数字。然而,并不是所有的字符串都是平等的。如果您试图将一个数字添加到一个不能转换为数字的字符串中,那么您将会收到一个错误。清单 13-6 显示了尝试这样做的代码。
清单 13-6 。向非数字字符串添加数字
variable = "name"
variable = variable + 1
该代码导致 Lua 虚拟机产生以下错误:
input:2: attempt to perform arithmetic on a string value (global 'variable')
所有的 Lua 算术运算符都可以转换类型。如果两个变量都是整数,那么结果值也是整数。如果一个或两个值都是浮点数,则结果是浮点数。最后,如果一个或两个值都是可以转换成数字的字符串,那么结果值就是浮点数。你可以在清单 13-4 的输出中看到这一点,其中print
显示的值是 2.0,0 代表一个浮点数。有些运算符,如除法运算符和指数运算符,总是返回用浮点数 表示的值。
这些例子展示了 Lua 编程语言的一个特性,它使非程序员更容易使用。你不需要像使用 C++ 时那样牢牢掌握变量的底层类型。不需要考虑是否有足够数量的字节来表示值 512,也不需要在char
、short
或int
之间进行选择。你也不需要关心如何处理 C 风格的字符串或者 C++ STL 字符串。只要在代码中随时给变量赋值,任何变量都可以存储 Lua 支持的任何类型。
使用函数
上一节展示了 Lua 有一些您可以调用的内置函数。也可以使用function
关键字创建自己的函数。清单 13-7 创建一个 Lua 函数。
清单 13-7 。创建和调用函数
variable = "name"
function ChangeName()
variable = "age"
end
print(variable)
ChangeName()
print(variable)
清单 13-7 从定义一个存储值“name”的变量开始。接下来是一个函数定义,将variable
的值改为“age”。函数中的代码在函数定义时不会被调用。这可以在打印调用生成的输出中看到。对print
的第一次调用产生输出name
,第二次调用产生输出age
。
这是一个有用的例子,因为它显示了默认情况下,Lua 变量本质上是全局的。由variable
存储的值被打印两次:一次在调用ChangeName
之前,一次在调用之后。如果variable
不是全球化的,你会期望两次的值是相同的。Lua 确实支持创建局部变量,但是您必须小心使用它们。清单 13-8 显示了当你使variable
局部化时会发生什么。
清单 13-8 。使variable
本地化
local variable = "name"
function ChangeName()
variable = "age"
end
print(variable)
ChangeName()
print(variable)
在清单 13-8 的中给variable
添加local
说明符对所示代码没有任何作用。将它设为局部变量实际上是告诉 Lua 虚拟机,可以在当前作用域的任何地方访问该变量——这意味着可以在当前文件的任何地方访问。如果您正在使用 Lua 演示,您可以想象用于输入代码的文本框是一个单独的 Lua 文件。为了防止ChangeName
函数访问variable
的同一个实例,你也必须在这个变量上使用local
关键字,如清单 13-9 所示。
清单 13-9 。制作ChangeName variable
L ocal
local variable = "name"
function ChangeName()
local variable = "age"
end
print(variable)
ChangeName()
print(variable)
对清单 13-9 中的中的print
的两次调用都导致值“name”被打印到输出窗口。我建议将所有变量都设为局部变量,以确保代码不太可能引入难以追踪的错误,这些错误是由于一次在多个地方无意中使用了相同的变量名而导致的。
Lua 中的函数总是返回值。清单 13-9 中的ChangeName
函数没有指定返回值,所以它隐式返回nil
。这可以在清单 13-10 中看到。
清单 13-10 。功能返回nil
function GetValue()
local variable = "age"
end
local value = GetValue()
print(value)
该代码将nil
返回给variable value,
,并由print
函数打印出来。nil
值是 C++ 中nullptr
的 Lua 等价物。它意味着没有值,而不是表示 0。试图操纵nil
值会导致如下所示的 Lua 错误:
input:8: attempt to perform arithmetic on a nil value (local 'value')
当value
存储nil
时,试图将 1 加到value
会产生此错误。您可以通过从GetValue
函数中正确返回值来避免错误,如清单 13-11 所示。
清单 13-11 。从函数中正确返回
function GetValue()
return "age"
end
local value = GetValue()
print(value)
这个清单显示了在 C++ 中可以像使用函数一样使用函数return
。不过,Lua 的return
语句与 C++ 中的return
不同。您可以使用逗号操作符(,
)从函数中返回多个值。清单 13-12 展示了这一点。
清单 13-12 。多个返回值
function GetValues()
return "name", "age"
end
local name, age = GetValues()
print(name)
print(age)
清单 13-12 显示,要从一个函数中返回和存储多个值,在定义函数和调用函数时,必须在return
语句和赋值语句中使用逗号运算符。
使用表格
Lua 提供了表作为存储信息集合的手段。一个表既可以用作基于整数的索引的标准数组,也可以用作键值对的关联数组。你用花括号创建一个表格,如清单 13-13 所示。
清单 13-13 。创建表格
newTable = {}
这段代码只是创建了一个现在可以用来存储值的表。关联表可以使用任何类型的变量作为关键字。对于字符串、浮点、整数,甚至其他表都是如此。清单 13-14 显示了如何使用 Lua 表作为关联数组。
清单 13-14 。向关联数组中添加值
newTable = {}
newTable["value"] = 3.14
newTable[3.14] = "value"
keyTable = {}
newTable[keyTable] = "VALID"
print(newTable["value"])
print(newTable[3.14])
print(newTable[keyTable])
清单 13-14 使用键向 Lua 表添加值。在这个清单中有使用字符串、浮点数和其他表作为键的例子,您可以看到如何使用数组操作符将值赋给表中的键以及从表中读取值。试图读取newTable[3.14]
处的值将导致在将任何值分配给该键之前返回nil
。这也是你从表中删除值的方法:将nil
分配给你想要删除的键。清单 13-15 显示了从表格中移除对象。
清单 13-15 。从表格中移除对象
newTable = {}
newTable["nilValue1"] = 1
newTable["nilValue2"] = 2
print(newTable["nilValue1"])
print(newTable["nilValue2"])
newTable["nilValue1"] = nil
print(newTable["nilValue1"])
print(newTable["nilValue2"])
Lua 表也可以用作 C 风格的数组,Lua 语言提供了帮助函数来帮助管理这些类型的数组。清单 13-16 显示了一个数组表的创建及其元素的修改。
清单 13-16 。创建 Lua 数组
newTable = {}
table.insert(newTable, "first")
table.insert(newTable, "second")
table.insert(newTable, "third")
print(newTable[2])
print(newTable[2])
table.insert(newTable, 2, "fourth")
print(newTable[2])
table.remove(newTable, 1)
print(newTable[1])
print(newTable[2])
print(newTable[3])
print(newTable[4])
清单 13-16 使用了table.insert
和table.remove
Lua 函数。您可以通过两种方式使用insert
函数:不使用索引,将元素添加到数组末尾;或者用一个索引作为第二个参数,将元素插入到数组中,并从该点开始向上移动一个位置。这向您展示了 Lua 数组的行为更像 C++ vector
。remove
函数获取您希望从数组中移除的索引。
Lua 还提供了一个#
操作符,可以用于数组样式的表。清单 13-17 展示了它的实际应用。
清单 13-17 。使用#
操作符
newTable = {}
table.insert(newTable, "first")
table.insert(newTable, "second")
table.insert(newTable, "third")
print(#newTable)
newTable[9] = "fourth"
print(newTable[9])
print(#newTable)
清单 13-17 中的操作符返回它能找到的最后一个连续索引。使用insert
方法,添加前三个元素没有问题;因此它们有连续的索引。然而,在 9 中手动添加的元素却没有。这使得您无法使用#
操作符来计算数组中元素的数量,除非您可以确定数组中的所有索引都是连续的。
使用流量控制
Lua 提供了一个if
语句、一个for
循环和一个while
循环来帮助你构建你的程序。这些可以用来做决策和循环表中的所有元素。清单 13-18 显示了 Lua if
语句。
清单 13-18 。使用 Lua if
语句
value1 = 1
value2 = 2
if value1 == value2 then
print("Are equal")
elseif value1 ~= value2 then
print("Not equal")
else
print("Shouldn't be here!")
end
Lua 的if
语句是通过在if…then
语句中创建一个计算结果为 not nil
和 not false
的表达式而形成的。if
块中的代码创建了自己的作用域,可以由自己的local
变量组成。提供elseif
语句是为了允许按顺序计算多个表达式,而else
语句可以提供一个默认的 required 行为。elseif
和else
语句都是可选的,不是必需的。使用关键字end
终止整个if
语句块。
当从 C++ 迁移到 Lua 并使用像if
这样的流控制语句时,有一些事情需要考虑。当使用if
语句时,将 0 值赋给变量会导致阳性测试。if
语句对not nil
和not false
求值,因此值为 0 表示真。清单 13-18 还显示了不等运算符,它在 Lua 中使用~
字符代替 C++ 语言中使用的!
。
这些情况也适用于while
语句,如清单 13-19 所示。
清单 13-19 。使用 Lua while
循环
value1 = 2
while value1 do
print("We got here! " .. value1)
value1 = value1 - 1
if value1 == -1 then
value1 = nil
end
end
这段代码使用了一个while
循环来显示值 0 在 Lua 控制语句中评估为真。输出如下所示:
We got here! 2
We got here! 1
We got here! 0
在if
语句被触发并将value1
的值设置为nil
后,循环最终终止。在清单 13-20 中显示了控制while
循环终止的更好方法。
清单 13-20 。更好的终止
value1 = 2
while value1 do
print("We got here! " .. value1)
value1 = value1 - 1
if value1 == -1 then
break
end
end
清单 13-20 使用一个break
语句来退出while
循环的执行。当来自 C++ 时,break
语句的工作方式与你预期的完全一样。清单 13-21 中显示了另一个退出循环的选项。
清单 13-21 。使用比较运算符离开循环
value1 = 2
while value1 >= 0 do
print("We got here! " .. value1)
value1 = value1 - 1
end
尽管值 0 在while
循环测试中导致真结果,但在正常操作环境下,0 的比较或任何其他有效比较最终返回假。这里将value1
的值与 0 进行比较,一旦值低于 0,循环就停止执行。
你可以使用 Lua for
循环来迭代算法。清单 13-22 显示了一个简单的for
循环。
清单 13-22 。Lua for
循环
for i=0, 10, 2 do
print(i)
end
这个for
循环打印数字 0、2、4、6、8 和 10。生成一个for
循环的语句需要一个起始位置(在本例中是一个变量及其值)、一个限制,最后是一个步骤。此示例创建一个变量并将其赋值为 0,循环直到变量大于限制,并在每次迭代时将步长添加到变量中。循环从 0 开始,每次迭代增加 2,当变量的值大于 10 时结束。如果步长为负,它将循环,直到变量的值小于极限值。
您还可以使用一个for
循环来迭代使用pairs
或ipairs
函数的表。清单 13-23 展示了这些在实际中的应用。
清单 13-23 。使用pairs
和ipairs
newTable = {}
newTable["first"] = 1
newTable["second"] = 2
newTable["third"] = 3
for key, value in pairs(newTable) do
print(key .. ": " .. value)
end
newTable = {}
table.insert(newTable, "first")
table.insert(newTable, "second")
table.insert(newTable, "third")
for index, value in ipairs(newTable) do
print(index .. ": " .. value)
end
pairs
函数返回关联数组表中每个元素的键和值,ipairs
函数返回数组样式表的数字索引。这段代码展示了 Lua 从一个函数返回多个值的能力的好处。
13-5.从 C++ 调用 Lua 函数
问题
您的程序中有一个任务将受益于 Lua 脚本提供的快速迭代能力。
解决办法
Lua 编程语言带有源代码,允许您在程序运行时编译和执行脚本。
它是如何工作的
Lua C++ API 为 Lua 状态的堆栈提供了一个编程接口。C++ API 可以操纵这个堆栈将参数传递给 Lua 代码,并从 Lua 接收值作为回报。这个功能允许您创建 Lua 源文件,然后这些文件可以充当 Lua 函数。这些 Lua 函数可以在你的程序运行时更新,允许你比单独使用 C++ 更快地迭代你的程序逻辑。
Lua APIs 是使用 C 编程语言提供的。这意味着,如果您希望采用更 C++ 风格的方法来使用 Lua,您必须创建代理对象。清单 13-24 展示了如何创建一个程序,从 C++ 中加载并执行一个 Lua 脚本作为函数。
清单 13-24 。调用一个简单的 Lua 脚本作为函数
#include <iostream>
#include "lua.hpp"
using namespace std;
class Lua
{
private:
lua_State* m_pLuaState{ nullptr };
public:
Lua()
: m_pLuaState{ luaL_newstate() }
{
if (m_pLuaState)
{
luaL_openlibs(m_pLuaState);
}
}
~Lua()
{
lua_close(m_pLuaState);
}
Lua(const Lua& other) = delete;
Lua& operator=(const Lua& other) = delete;
Lua(Lua&& rvalue) = delete;
Lua& operator=(Lua&& rvalue) = delete;
bool IsValid() const
{
return m_pLuaState != nullptr;
}
int LoadFile(const string& filename)
{
int status{ luaL_loadfile(m_pLuaState, filename.c_str()) };
if (status == 0)
{
lua_setglobal(m_pLuaState, filename.c_str());
}
return status;
}
int PCall()
{
return lua_pcall(m_pLuaState, 0, LUA_MULTRET, 0);
}
};
class LuaFunction
{
private:
Lua& m_Lua;
string m_Filename;
int PCall()
{
return m_Lua.PCall();
}
public:
LuaFunction(Lua& lua, const string& filename)
: m_Lua{ lua }
, m_Filename(filename)
{
m_Lua.LoadFile(m_Filename);
}
~LuaFunction() = default;
LuaFunction(const LuaFunction& other) = delete;
LuaFunction& operator=(const LuaFunction& other) = delete;
LuaFunction(LuaFunction&& rvalue) = delete;
LuaFunction& operator=(LuaFunction&& rvalue) = delete;
int Call()
{
m_Lua.GetGlobal(m_Filename);
return m_Lua.PCall();
}
};
int main(int argc, char* argv[])
{
Lua lua;
if (lua.IsValid())
{
const string filename{ "LuaCode1.lua" };
LuaFunction function(lua, filename);
function.Call();
}
return 0;
}
清单 13-24 展示了一种在单个类实现中包含所有 Lua C 函数的方法。这让您可以将所有这些方法的定义放在一个 C++ 文件中,并在整个程序中限制对 Lua 的依赖。因此,Lua
类负责维护管理程序 Lua 上下文的lua_State
指针。本示例创建一个限制复制或移动Lua
对象的能力的类;您可能需要做到这一点,但对于这些示例来说,这不是必需的。
Lua
类的构造函数调用luaL_newstate
函数。这个函数调用lua_newstate
函数并传递默认参数。如果您想为 Lua 状态机提供自己的内存分配器,可以直接调用lua_newstate
。对luaL_newstate
的成功调用导致m_pLuaState
字段存储该州的有效地址。如果这是真的,那么调用luaL_openlibs
函数。这个函数自动将 Lua 提供的库加载到您创建的状态中。如果不需要 Lua 内置的库功能,可以避免调用这个函数。
Lua
类析构函数负责调用lua_close
销毁luaL_newstate
在构造函数中创建的 Lua 上下文。IsValid
函数为您的调用代码提供了一个简单的方法来确定 Lua 上下文是否在构造函数中正确初始化。
LuaFunction
类存储了它用于上下文的Lua
类的引用。这个类再次阻止了复制和移动。构造函数引用了为其提供功能的Lua
对象和一个包含要加载的包含 Lua 源代码的文件名的字符串。构造函数使用m_Lua
对象调用LoadFile
方法并传递m_Filename
字段。LoadFile
方法调用luaL_loadfile
,后者读取文件,编译 Lua 源代码,并使用编译后的代码将一个 Lua 函数对象推到 Lua 堆栈的顶部。如果luaL_loadfile
调用成功,则调用lua_setglobal
函数。该函数从堆栈中获取顶层对象,并将其分配给一个具有指定名称的全局对象。在这种情况下,由luaL_loadfile
创建的函数对象被分配给一个以源文件名命名的全局变量。
main
函数用一个名为LuaCode1.lua
的文件创建一个LuaFunction
对象。该文件的来源如清单 13-25 所示。
清单 13-25 。来自LuaCode1.lua
的代码
print("Printing From Lua!")
这个 Lua 代码导致一个简单的消息被打印到控制台。这发生在main
函数调用LuaFunction::Call
方法 时。该方法使用Lua::GetGlobal
函数 将具有给定名称的全局对象移动到堆栈顶部。在这种情况下,m_Filename
变量将在LoadFile
方法中创建的函数对象移动到堆栈上。Lua::PCall
方法 然后调用离栈顶最近的函数。该程序生成的输出如图图 13-2 所示。
图 13-2 。运行清单 13-24 和清单 13-25 中的代码生成的输出
清单 13-24 没有初始化任何被 Lua 脚本消费的数据。您可以通过创建表示 Lua 类型的类来处理这个问题。清单 13-26 创建一个LuaTable
类来用 C++ 创建 Lua 表,然后 Lua 可以访问这些表。
清单 13-26 。在 C++ 中创建 Lua 表
#include <iostream>
#include "lua.hpp"
#include <vector>
using namespace std;
class Lua
{
private:
lua_State* m_pLuaState{ nullptr };
public:
Lua()
: m_pLuaState{ luaL_newstate() }
{
if (m_pLuaState)
{
luaL_openlibs(m_pLuaState);
}
}
~Lua()
{
lua_close(m_pLuaState);
}
Lua(const Lua& other) = delete;
Lua& operator=(const Lua& other) = delete;
Lua(Lua&& rvalue) = delete;
Lua& operator=(Lua&& rvalue) = delete;
bool IsValid() const
{
return m_pLuaState != nullptr;
}
int LoadFile(const string& filename)
{
int status{ luaL_loadfile(m_pLuaState, filename.c_str()) };
if (status == 0)
{
lua_setglobal(m_pLuaState, filename.c_str());
Pop(1);
}
return status;
}
int PCall()
{
return lua_pcall(m_pLuaState, 0, LUA_MULTRET, 0);
}
void NewTable(const string& name)
{
lua_newtable(m_pLuaState);
lua_setglobal(m_pLuaState, name.c_str());
}
void GetGlobal(const string& name)
{
lua_getglobal(m_pLuaState, name.c_str());
}
void PushNumber(double number)
{
lua_pushnumber(m_pLuaState, number);
}
void SetTableValue(double index, double value)
{
PushNumber(index);
PushNumber(value);
lua_rawset(m_pLuaState, -3);
}
double GetNumber()
{
return lua_tonumber(m_pLuaState, -1);
}
void Pop(int number)
{
lua_pop(m_pLuaState, number);
}
};
class LuaTable
{
private:
Lua& m_Lua;
string m_Name;
public:
LuaTable(Lua& lua, const string& name)
: m_Lua{ lua }
, m_Name(name)
{
m_Lua.NewTable(m_Name);
}
void Set(const vector<int>& values)
{
Push();
for (unsigned int i = 0; i < values.size(); ++i)
{
m_Lua.SetTableValue(i + 1, values[i]);
}
m_Lua.Pop(1);
}
void Push()
{
m_Lua.GetGlobal(m_Name);
}
};
class LuaFunction
{
private:
Lua& m_Lua;
string m_Filename;
int PCall()
{
return m_Lua.PCall();
}
protected:
int Call()
{
m_Lua.GetGlobal(m_Filename);
return m_Lua.PCall();
}
double GetReturnValue()
{
double result{ m_Lua.GetNumber() };
m_Lua.Pop(1);
return result;
}
public:
LuaFunction(Lua& lua, const string& filename)
: m_Lua{ lua }
, m_Filename( filename )
{
int status{ m_Lua.LoadFile(m_Filename) };
}
};
class PrintTable
: public LuaFunction
{
public:
PrintTable(Lua& lua, const string& filename)
: LuaFunction(lua, filename)
{
}
double Call(LuaTable& table)
{
double sum{};
int status{ LuaFunction::Call() };
if (status)
{
throw(status);
}
else
{
sum = LuaFunction::GetReturnValue();
}
return sum;
}
};
int main(int argc, char* argv[])
{
Lua lua;
if (lua.IsValid())
{
int loop = 2;
while (loop > 0)
{
const string tableName("cTable");
LuaTable table(lua, tableName);
vector<int> values{ 1, 2, 3, 4, 5 };
table.Set(values);
const string filename{ "LuaCode.lua" };
PrintTable printTableFunction(lua, filename);
try
{
double result{ printTableFunction.Call(table) };
cout << "Result: " << result << endl;
}
catch (int error)
{
cout << "Call error: " << error << endl;
}
cout << "Waiting" << endl;
int input;
cin >> input;
--loop;
}
}
return 0;
}
清单 13-26 向Lua
类添加了一个LuaTable
类以及相关的方法来管理表格。lua_newtable
函数创建一个新表,并将其推送到堆栈中。然后在LuaTable
构造函数中用提供的名字将element
赋给一个全局变量。使用Lua::SetTableValue
方法 将值添加到表格中。这个方法只支持表的数字索引,并通过将两个数字压入堆栈来工作:表中要分配的索引和分配给该索引的值。lua_rawset
函数将一个值赋给表上的一个索引,所讨论的表存在于所提供的索引处。堆栈上的第一个元素被-1 引用,这将是值;此时堆栈上的第二个元素是索引;第三个元素是表,所以值-3 被传递给lua_rawset
函数。该调用从堆栈中弹出索引和值,因此再次在位置-1 找到该表。
LuaFunction
类被继承到一个名为PrintTable
的新类中。这个类提供了一个新的call
方法,该方法知道如何从提供的 Lua 脚本中检索返回值。清单 13-27 中的 Lua 代码展示了为什么这是必要的。
清单 13-27 。LuaCode2.lua
来源
local x = 0
for i = 1, #cTable do
print(i, cTable[i])
x = x + cTable[i]
end
return x
这段代码遍历用 C++ 建立的cTable
表并打印出值。它还计算表中所有值的总和,并使用堆栈将它们返回给调用代码。
C++ main
函数创建一个表,并使用一个vector
给它分配五个整数。PrintTable
类用LuaCode2.lua
文件创建了一个 C++ Lua 函数。调用这个函数,使用Lua::GetReturnValue
函数 从堆栈中检索返回值。
在main
中最值得注意的是重新加载 Lua 脚本和更新运行时执行的代码的能力。使用cin
时main
功能停止。在等待的过程中,您可以修改 Lua 脚本,并在解除阻塞执行后看到反映的变化。图 13-3 显示了证明这可能发生的输出。
图 13-3 。显示脚本可以在运行时更改的输出
该输出显示,更改 Lua 代码并重新加载函数会替换给定全局变量处的代码。我在脚本中添加了一行输出:您可以在图中看到这一行“我更改了它!”已打印。
13-6.从 Lua 调用 C 函数
问题
您有一些高度复杂的代码,它们将受益于 C/C++ 代码提供的高性能,但是您希望能够从 Lua 调用这些函数。
解决办法
Lua 提供了lua_CFunction
类型,让您创建可以被 Lua 代码引用的 C 函数。
它是如何工作的
Lua API 提供了一个类型lua_CFunction
,它本质上决定了可以与 C 函数一起使用的签名,以允许从 Lua 调用它。清单 13-28 展示了一个例子,它创建了一个函数,可以添加 Lua 提供给它的所有参数。
清单 13-28 。从 Lua 调用 C 函数
#include <iostream>
#include "lua.hpp"
#include <vector>
using namespace std;
namespace
{
int Sum(lua_State *L)
{
unsigned int numArguments{ static_cast<unsigned int>(lua_gettop(L)) };
lua_Number sum{ 0 };
for (unsigned int i = 1; i <= numArguments; ++i)
{
if (!lua_isnumber(L, i))
{
lua_pushstring(L, "incorrect argument");
lua_error(L);
}
sum += lua_tonumber(L, i);
}
lua_pushnumber(L, sum / numArguments);
lua_pushnumber(L, sum);
return 2;
}
}
class Lua
{
private:
lua_State* m_pLuaState{ nullptr };
public:
Lua()
: m_pLuaState{ luaL_newstate() }
{
if (m_pLuaState)
{
luaL_openlibs(m_pLuaState);
}
}
~Lua()
{
lua_close(m_pLuaState);
}
Lua(const Lua& other) = delete;
Lua& operator=(const Lua& other) = delete;
Lua(Lua&& rvalue) = delete;
Lua& operator=(Lua&& rvalue) = delete;
bool IsValid() const
{
return m_pLuaState != nullptr;
}
int LoadFile(const string& filename)
{
int status{ luaL_loadfile(m_pLuaState, filename.c_str()) };
if (status == 0)
{
lua_setglobal(m_pLuaState, filename.c_str());
}
return status;
}
int PCall()
{
return lua_pcall(m_pLuaState, 0, LUA_MULTRET, 0);
}
void NewTable(const string& name)
{
lua_newtable(m_pLuaState);
lua_setglobal(m_pLuaState, name.c_str());
}
void GetGlobal(const string& name)
{
lua_getglobal(m_pLuaState, name.c_str());
}
void PushNumber(double number)
{
lua_pushnumber(m_pLuaState, number);
}
void SetTableValue(double index, double value)
{
PushNumber(index);
PushNumber(value);
lua_rawset(m_pLuaState, -3);
}
double GetNumber()
{
return lua_tonumber(m_pLuaState, -1);
}
void Pop(int number)
{
lua_pop(m_pLuaState, number);
}
void CreateCFunction(const string& name, lua_CFunction function)
{
lua_pushcfunction(m_pLuaState, function);
lua_setglobal(m_pLuaState, name.c_str());
}
};
class LuaTable
{
private:
Lua& m_Lua;
string m_Name;
public:
LuaTable(Lua& lua, const string& name)
: m_Lua{ lua }
, m_Name(name)
{
m_Lua.NewTable(m_Name);
}
void Set(const vector<int>& values)
{
Push();
for (unsigned int i = 0; i < values.size(); ++i)
{
m_Lua.SetTableValue(i + 1, values[i]);
}
m_Lua.Pop(1);
}
void Push()
{
m_Lua.GetGlobal(m_Name);
}
};
class LuaFunction
{
private:
Lua& m_Lua;
string m_Filename;
protected:
int PCall()
{
m_Lua.GetGlobal(m_Filename);
return m_Lua.PCall();
}
double GetReturnValue()
{
double result{ m_Lua.GetNumber() };
m_Lua.Pop(1);
return result;
}
public:
LuaFunction(Lua& lua, const string& filename)
: m_Lua{ lua }
, m_Filename(filename)
{
int status{ m_Lua.LoadFile(m_Filename) };
}
};
class PrintTable
: public LuaFunction
{
public:
PrintTable(Lua& lua, const string& filename)
: LuaFunction(lua, filename)
{
}
double Call(LuaTable& table)
{
double sum{};
int status{ LuaFunction::PCall() };
if (status)
{
throw(status);
}
else
{
sum = LuaFunction::GetReturnValue();
}
return sum;
}
};
int main(int argc, char* argv[])
{
Lua lua;
if (lua.IsValid())
{
const string functionName("Sum");
lua.CreateCFunction(functionName, Sum);
const string tableName("cTable");
LuaTable table(lua, tableName);
vector<int> values{ 1, 2, 3, 4, 5 };
table.Set(values);
const string filename{ "LuaCode3.lua" };
PrintTable printTableFunction(lua, filename);
try
{
double result{ printTableFunction.Call(table) };
cout << "Result: " << result << endl;
}
catch (int error)
{
cout << "Call error: " << error << endl;
}
cout << "Waiting" << endl;
int input;
cin >> input;
}
return 0;
}
清单 13-28 中的Sum
函数展示了 C 函数必须如何与 Lua 接口。签名很简单:可以从 Lua 调用的 C 函数返回一个整数,并接收一个指向lua_State
对象的指针作为参数。当 Lua 调用一个 C 函数时,它将传递的参数数量推到 Lua 栈顶。该值由调用的函数读取,然后该函数可以循环并从堆栈中提取适当数量的元素。然后,C 函数将适当数量的结果推送到堆栈上,并返回调用代码必须从堆栈中弹出的元素数量。
Lua::CreateCFunction
方法使用lua_pushcfunction
方法将一个lua_CFunction
对象推到堆栈上,然后使用lua_setglobal
将其分配给全局上下文中的一个命名对象。main
函数简单地调用CreateCFunction
,并提供要在 Lua 中使用的名字以及要使用的函数指针。调用这个函数的 Lua 代码如清单 13-29 所示。
清单 13-29 。Lua 代码调用 C 函数
local x = 0
for i = 1, #cTable do
print(i, cTable[i])
x = x + cTable[i]
end
local average, sum = Sum(cTable[1], cTable[2], cTable[3])
print("Average: " .. average)
print("Sum: " .. sum)
return sum
这个 Lua 代码显示了对Sum
的调用,并检索了average
和sum
的值。
13-7.创建异步 Lua 函数
问题
您有一个长时间运行的 Lua 操作,您希望防止它阻塞程序的执行。
解决办法
Lua 允许您创建协程。这些可以从中产生,以让您的程序继续执行,并允许创建行为良好、长期运行的 Lua 任务。每个协程接收它自己独特的 Lua 上下文。
它是如何工作的
Lua 编程语言允许创建协程。协程与普通函数的不同之处在于,它们可以从 Lua 调用coroutine.yield
函数来通知状态机它们的执行被挂起了。C API 提供了一个resume
函数,您可以调用它在一段时间后唤醒协程,以允许线程检查它所等待的情况是否已经发生。这可能是因为您想要等待动画完成,或者 Lua 脚本正在等待从 I/O 进程获取信息,例如从文件中读取或访问服务器上的数据。
使用lua_newthread
函数创建一个 Lua 协程。尽管名字如此,Lua 协程是在发出lua_resume
调用的线程中执行的。向lua_resume
调用传递一个指向包含协程堆栈的lua_State
对象的指针。在栈上执行的代码是 Lua 函数对象,在调用lua_resume
时,它最靠近栈顶。清单 13-30 显示了设置 Lua 线程并执行其代码所需的 C++ 代码。
清单 13-30 。创建 Lua 协程
#include <iostream>
#include <lua.hpp>
using namespace std;
class Lua
{
private:
lua_State* m_pLuaState{ nullptr };
bool m_IsThread{ false };
public:
Lua()
: m_pLuaState{ luaL_newstate() }
{
if (m_pLuaState)
{
luaL_openlibs(m_pLuaState);
}
}
Lua(lua_State* pLuaState)
: m_pLuaState{ pLuaState }
{
if (m_pLuaState)
{
luaL_openlibs(m_pLuaState);
}
}
~Lua()
{
if (!m_IsThread && m_pLuaState)
{
lua_close(m_pLuaState);
}
}
Lua(const Lua& other) = delete;
Lua& operator=(const Lua& other) = delete;
Lua(Lua&& rvalue)
: m_pLuaState( rvalue.m_pLuaState )
, m_IsThread( rvalue.m_IsThread )
{
rvalue.m_pLuaState = nullptr;
}
Lua& operator=(Lua&& rvalue)
{
if (this != &rvalue)
{
m_pLuaState = rvalue.m_pLuaState;
m_IsThread = rvalue.m_IsThread;
rvalue.m_pLuaState = nullptr;
}
}
bool IsValid() const
{
return m_pLuaState != nullptr;
}
int LoadFile(const string& filename)
{
int status{ luaL_loadfile(m_pLuaState, filename.c_str()) };
if (status == 0)
{
lua_setglobal(m_pLuaState, filename.c_str());
}
return status;
}
void GetGlobal(const string& name)
{
lua_getglobal(m_pLuaState, name.c_str());
}
Lua CreateThread()
{
Lua threadContext(lua_newthread(m_pLuaState));
threadContext.m_IsThread = true;
return move(threadContext);
}
int ResumeThread()
{
return lua_resume(m_pLuaState, m_pLuaState, 0);
}
};
class LuaFunction
{
private:
Lua& m_Lua;
string m_Filename;
public:
LuaFunction(Lua& lua, const string& filename)
: m_Lua{ lua }
, m_Filename(filename)
{
int status{ m_Lua.LoadFile(m_Filename) };
}
void Push()
{
m_Lua.GetGlobal(m_Filename);
}
};
class LuaThread
{
private:
Lua m_Lua;
LuaFunction m_LuaFunction;
int m_Status{ -1 };
public:
LuaThread(Lua&& lua, const string& functionFilename)
: m_Lua(move(lua))
, m_LuaFunction(m_Lua, functionFilename)
{
}
~LuaThread() = default;
LuaThread(const LuaThread& other) = delete;
LuaThread& operator=(const LuaThread& other) = delete;
LuaThread(LuaThread&& rvalue) = delete;
LuaThread& operator=(LuaThread&& rvalue) = delete;
void Resume()
{
if (!IsFinished())
{
if (m_Status == -1)
{
m_LuaFunction.Push();
}
m_Status = m_Lua.ResumeThread();
}
}
bool IsFinished() const
{
return m_Status == LUA_OK;
}
};
int main(int argc, char* argv[])
{
Lua lua;
if (lua.IsValid())
{
const string functionName("LuaCode4.lua");
LuaThread myThread(lua.CreateThread(), functionName);
while (!myThread.IsFinished())
{
myThread.Resume();
cout << "myThread yielded or finished!" << endl;
}
cout << "myThread finished!" << endl;
}
return 0;
}
清单 13-30 中的Lua
类包含一个指向lua_State
对象的指针和一个bool
变量,该变量指示是否创建了一个特定的对象来处理 Lua 线程。这对于确保只有一个Lua
对象负责在其析构函数中调用lua_close
是必要的。您可以看到这个bool
值是在~Lua
方法中检查的。
在Lua::CreateThread
方法中m_IsThread bool
被设置为真。这个方法调用lua_newthread
函数,并将新的lua_State
指针传递给一个新构造的Lua
对象。然后这个对象将m_IsThread bool
设置为真,并从函数中返回。使用 move 语义返回Lua
对象。这确保了在任何时候都不会有单个Lua
对象的任何副本,这是通过在复制构造函数和复制赋值操作符中指定的delete
关键字来实现的。仅定义了移动构造函数和移动赋值运算符。
Lua::Resume
方法 也显示在清单 13-30 中。这个方法负责启动或恢复 Lua 协程的执行。
LuaThread
类负责管理一个 Lua 协程。构造函数接受一个对Lua
对象的rvalue
引用和一个包含要加载的文件名的string
。class
有存储Lua
对象和一个LuaFunction
对象的字段,该对象将用于将函数Push
到协程的堆栈上。m_Status
字段确定协程何时完成执行。它被初始化为-1,因为 Lua 不使用这个值来表示状态。当协程执行完成时,从lua_resume
返回LUA_OK
值,当协程让步时,返回LUA_YIELD
值。LuaThread::Resume
功能首先检查状态是否已经设置为LUA_OK
;如果有,那就什么都不做。如果m_Status
变量包含-1,那么m_LuaFunction
对象被压入堆栈。用Lua::ResumeThread
返回的值更新m_Status
变量。
main
函数通过创建一个LuaThread
对象并在一个while
循环中调用LuaThread::Resume
来使用所有这些功能,该循环一直执行到IsFinished
在myThread
对象上返回 true。LuaCode4.lua
文件包含来自清单 13-31 的 Lua 代码,它在一个循环中包含几个产量。
清单 13-31 。LuaCode4.lua
来源
for i=1, 10, 1 do
print("Going for yield " .. i .. "!")
coroutine.yield()
end
这是一个如何在 Lua 代码中使用coroutine.yield
函数的简单例子。在运行中的 Lua 脚本中执行这个 Lua 函数时,lua_resume
C 函数返回LUA_YIELD
。图 13-4 显示了运行包含清单 13-30 中的 C++ 代码和清单 13-31 中的 Lua 代码的组合的结果。
图 13-4 。结合执行清单 13-30 和清单 13-31 产生的输出
十四、3D 图形编程
C++ 是高性能应用程序开发人员的首选编程语言。这通常包括需要向用户显示 3D 图形的应用程序。3D 图形在医疗应用、设计应用和视频游戏中很常见。所有这些类型的应用程序都要求将响应能力作为一个关键的可用性特性。这使得 C++ 语言成为这类程序的完美选择,因为程序员可以针对特定的硬件平台进行优化。
微软为 Windows 操作系统提供构建 3D 应用程序的专有 DirectX API。然而,本章着眼于使用 OpenGL API 编写一个简单的 3D 程序。Windows、OS X 和大多数 Linux 发行版都支持 OpenGL 在这种情况下,这是一个完美的选择,因为您可能会使用这些操作系统中的任何一个。
OpenGL 编程的一个比较乏味的方面是,如果你的目标不止一个,就需要在多个操作系统中设置和管理窗口。GLFW 包使这项工作变得更加容易,它将这项任务抽象在一个 API 后面,因此您不必担心细节。
14-1.GLFW 简介
问题
您正在编写一个包含 3D 图形的跨平台应用程序,并且您想要一种快速启动和运行的方法。
解决办法
GLFW 抽象出为许多流行的操作系统创建和管理窗口的任务。
它是如何工作的
GLFW API 是用 C 编程语言编写的,因此可以毫无问题地在 C++ 应用程序中使用。该 API 可从www.glfw.org
下载。您还可以在同一网站上阅读 API 的文档。配置和构建 GLFW 库的说明经常变化,因此本章不包括这些说明。在撰写本文时,可以在www.glfw.org/docs/latest/compile.html
找到构建 GLFW 的最新说明。
GLFW 的说明目前涉及使用 CMake 构建一个项目,然后可以使用该项目编译一个库,您可以将该库链接到您自己的项目中。一旦你建立并运行了这个,你就可以使用清单 14-1 中的代码来运行一个初始化 OpenGL 的程序,并为你的程序创建一个窗口。
清单 14-1 。一个简单的 GLFW 程序
#include "GLFW\glfw3.h"
int*main()*
{
GLFWwindow* window;
/* Initialize the library */
if (!glfwInit())
return -1;
/* Create a windowed mode window and its OpenGL context */
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
/* Make the window's context current */
glfwMakeContextCurrent(window);
/* Loop until the user closes the window */
while (!glfwWindowShouldClose(window))
{
/* Render here */
/* Swap front and back buffers */
glfwSwapBuffers(window);
/* Poll for and process events */
glfwPollEvents();
}
glfwTerminate();
return 0;
}
清单 14-1 中的代码是 GLFW 网站上提供的示例程序,用于确保您的构建工作正常。它通过调用glfwInit
来初始化glfw
库。使用glfwCreateWindow
功能创建一个窗口。该示例创建一个分辨率为 640 × 480、标题为“Hello World”的窗口。如果窗口创建失败,则调用glfwTerminate
函数。如果成功,程序调用glfwMakeContextCurrent
。OpenGL API 支持多个渲染上下文,当您想要渲染时,您必须确保您的上下文是当前上下文。程序的main
循环继续,直到glfwWindowShouldClose
函数返回true
。glfwSwapBuffers
功能负责用后台缓冲交换前台缓冲。双缓冲渲染有助于防止用户看到未完成的动画帧。当程序渲染到第二个缓冲区时,图形卡可以显示一个缓冲区。这些缓冲区在每帧结束时交换。glfwPollEvents
函数负责与操作系统通信并接收任何消息。程序以调用glfwTerminate
关闭所有东西结束。
OpenGL API 通过扩展提供了许多功能,这意味着您正在使用的功能可能不被您正在工作的平台直接支持。幸运的是,GLEW 库可以帮助在多种平台上使用 OpenGL 扩展。同样,获取、构建和链接该库的说明会随时发生变化。最新信息可以在http://glew.sourceforge.net
从 GLEW 网站获得。
一旦你启动并运行了 GLEW,你可以使用清单 14-2 中的glewInit
函数调用来初始化这个库。
清单 14-2 。正在初始化 GLEW
#include <GL/glew.h>
#include "GLFW/glfw3.h"
int main(void)
{
GLFWwindow* window;
// Initialize the library
if (!glfwInit())
{
return -1;
}
// Create a windowed mode window and its OpenGL context
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
// Make the window's context current
glfwMakeContextCurrent(window);
GLenum glewError{ glewInit() };
if (glewError != GLEW_OK)
{
return -1;
}
// Loop until the user closes the window
while (!glfwWindowShouldClose(window))
{
// Swap front and back buffers
glfwSwapBuffers(window);
// Poll for and process events
glfwPollEvents();
}
glfwTerminate();
return 0;
}
重要的是,这一步发生在你有一个有效的和当前的 OpenGL 上下文之后,因为 GLEW 库依赖于此来从 OpenGL API 加载你可能正在使用的最常见的扩展。
本书附带了一些示例应用程序,其中包含并配置了 GLEW 和 GLFW。如果您想查看已配置为使用这些库的项目,您应该下载这些库。此外,在库的网站(http://glew.sourceforge.net/install.html
和www.glfw.org/download.html
)上可以找到优秀的文献。
14-2.渲染一个三角形
问题
您希望在应用程序中呈现 3D 对象。
解决办法
OpenGL 提供 API 来配置图形卡上的渲染管道,并在屏幕上显示 3D 对象。
它是如何工作的
OpenGL 是一个图形库,允许应用程序向计算机中的 GPU 发送数据,以将图像渲染到窗口中。这份食谱向你介绍了在现代计算机系统上使用 OpenGL 时将图形渲染到窗口所必需的三个概念。首先是几何的概念。
对象的几何由顶点和索引的集合组成。顶点指定空间中顶点应该呈现在屏幕上的点。一个顶点通过 GPU,在不同的点对它应用不同的操作。这个方法绕过了顶点的大部分处理,而是在所谓的normalized device coordinates
中指定顶点。GPU 使用顶点着色器来转换顶点,以生成位于规范化立方体内的顶点。然后将这些顶点传递给片段着色器,片段用于确定在给定点写入帧缓冲区的输出颜色。当你阅读本章的食谱时,你会学到更多关于这些操作的知识。
清单 14-3 中的代码展示了Geometry
类以及如何使用它来指定顶点和索引的存储。
清单 14-3 。Geometry Class
using namespace std;
class Geometry
{
public:
using Vertices = vector < float >;
using Indices = vector < unsigned short >;
private:
Vertices m_Vertices;
Indices m_Indices;
public:
Geometry() = default;
~Geometry() = default;
void SetVertices(const Vertices& vertices)
{
m_Vertices = vertices;
}
Vertices::size_type GetNumVertices() const
{
return m_Vertices.size();
}
Vertices::const_pointer GetVertices() const
{
return m_Vertices.data();
}
void SetIndices(const Indices& indices)
{
m_Indices = indices;
}
Indices::size_type GetNumIndices() const
{
return m_Indices.size();
}
Indices::const_pointer GetIndices() const
{
return m_Indices.data();
}
};
Geometry
类包含两个向量别名。第一个别名用于定义代表float
s 的vector
的类型。该类型用于存储Geometry
类中的顶点。第二个类型别名定义了一个unsigned short
s 的vector
,该类型别名用于创建用于存储索引的m_Indices
向量。
使用 OpenGL 时,索引是一个有用的工具,因为它们允许您减少顶点数据中的重复顶点。网格通常由一组三角形组成,每个三角形都与其他三角形共享边,以创建一个没有任何洞的完整形状。这意味着不在对象边缘的单个顶点在多个三角形之间共享。使用索引可以创建网格的所有顶点,然后使用索引来表示 OpenGL 读取顶点以创建网格的各个三角形的顺序。你会在这个配方的后面看到顶点和索引的定义。
典型的 OpenGL 程序由多个着色器程序组成。着色器允许您控制 OpenGL 渲染管道的多个阶段的行为。此时,您需要能够创建一个顶点着色器和一个片段着色器,它们可以作为 GPU 的单个管道。OpenGL 通过让您独立创建顶点着色器和片段着色器并将它们链接到单个着色器程序中来实现这一点。你通常有不止一个这样的类,所以清单 14-4 中的Shader
基类展示了如何创建一个在多个派生着色器程序中共享的基类。
清单 14-4 。Shader
类
class Shader
{
private:
void LoadShader(GLuint id, const std::string& shaderCode)
{
const unsigned int NUM_SHADERS{ 1 };
const char* pCode{ shaderCode.c_str() };
GLint length{ static_cast<GLint>(shaderCode.length()) };
glShaderSource(id, NUM_SHADERS, &pCode, &length);
glCompileShader(id);
glAttachShader(m_ProgramId, id);
}
protected:
GLuint m_VertexShaderId{ GL_INVALID_VALUE };
GLuint m_FragmentShaderId{ GL_INVALID_VALUE };
GLint m_ProgramId{ GL_INVALID_VALUE };
std::string m_VertexShaderCode;
std::string m_FragmentShaderCode;
public:
Shader() = default;
virtual ~Shader() = default;
virtual void Link()
{
m_ProgramId = glCreateProgram();
m_VertexShaderId = glCreateShader(GL_VERTEX_SHADER);
LoadShader(m_VertexShaderId, m_VertexShaderCode);
m_FragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
LoadShader(m_FragmentShaderId, m_FragmentShaderCode);
glLinkProgram(m_ProgramId);
}
virtual void Setup(const Geometry& geometry)
{
glUseProgram(m_ProgramId);
}
};
Shader
类是你第一次看到 OpenGL API 的使用。该类包含用于存储 OpenGL 提供的 id 的变量,这些 id 充当顶点和片段着色器以及着色器程序的句柄。当m_ProgramId
字段被赋予glCreateProgram
方法的结果时,它在Link
方法中被初始化。m_VertexShaderId
被赋予glCreateShader
程序的值,该值被传递给GL_VERTEX_SHADER
变量。使用同一个变量初始化了m_FragmentShaderId
变量,但是它传递给了GL_FRAGMENT_SHADER
变量。您可以使用LoadShader
方法为顶点着色器或片段着色器加载着色器代码。当在Link
方法中两次调用LoadShader
方法时,您可以看到这一点:第一次使用m_VertexShaderId
和m_VertexShaderCode
变量作为参数,第二次使用m_FragmentShaderId
和m_FragentShaderCode
变量。Link
方法以调用glLinkProgram
结束。
LoadShader
方法负责将着色器源代码附加到着色器 ID,编译着色器,并将其附加到相关的 OpenGL 着色器程序。Setup
方法在渲染对象时使用,它告诉 OpenGL 你想让这个着色器程序成为使用中的活动着色器。这个配方需要一个着色器程序在屏幕上渲染一个三角形。这个着色器程序是通过从清单 14-4 中的Shader
类派生出一个名为BasicShader
的类来创建的,如清单 14-5 所示。
清单 14-5 。BasicShader
类
class BasicShader
: public Shader
{
private:
GLint m_PositionAttributeHandle;
public:
BasicShader()
{
m_VertexShaderCode =
"attribute vec4 a_vPosition; \n"
"void main(){ \n"
" gl_Position = a_vPosition; \n"
"} \n";
m_FragmentShaderCode =
"#version 150 \n"
"precision mediump float; \n"
"void main(){ \n"
" gl_FragColor = vec4(0.2, 0.2, 0.2, 1.0); \n"
"} \n";
}
~BasicShader() override = default;
void Link() override
{
Shader::Link();
GLint success;
glGetProgramiv(m_ProgramId, GL_ACTIVE_ATTRIBUTES, &success);
m_PositionAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vPosition");
}
void Setup(const Geometry& geometry) override
{
Shader::Setup(geometry);
glVertexAttribPointer(
m_PositionAttributeHandle,
3,
GL_FLOAT,
GL_FALSE,
0,
geometry.GetVertices());
glEnableVertexAttribArray(m_PositionAttributeHandle);
}
};
BasicShader
类从在其构造函数中初始化来自Shader
类的受保护的m_VertexShaderCode
和m_FragmentShaderCode
变量开始。Link
方法负责调用基类Link
方法,然后检索着色器代码中属性的句柄。Setup
方法还调用基类中的Setup
方法。然后,它在着色器程序中设置属性。属性 是一个变量,它从应用程序代码中使用 OpenGL API 函数设置的数据流或字段中接收数据。在这种情况下,属性是 GL 着色语言(GLSL) 代码中的vec4
字段。GLSL 用于编写 OpenGL 着色器代码;这种语言是基于 C 的,因此很熟悉,但是它包含了自己的类型和与应用程序端 OpenGL 调用进行通信所必需的关键字。顶点着色器代码中的a_vPosition vec4
属性负责接收发送给 OpenGL 进行渲染的顶点流中的每个位置。使用glGetAttribLocation
OpenGL API 函数检索属性的句柄,该函数获取程序 ID 和要检索的属性的名称。顶点位置的属性句柄可以与Setup
方法中的glVertexAttribPointer
函数一起使用。该方法将属性句柄作为参数,后跟每个顶点的元素数量。在这种情况下,顶点由 x,y,z 分量提供;因此,数字 3 被传递给size
参数。The GL_FLOAT
值指定顶点是浮点型的。GL_FALSE
告诉 OpenGL,当 API 接收到顶点时,不应将其规范化。0 值告诉 OpenGL 顶点数据位置之间的间隙大小;在这种情况下,没有间隙,所以可以传递 0。最后,提供一个指向顶点数据的指针。在这个函数调用之后,调用glEnableVertexAttribArray
函数来告诉 OpenGL 应该使用在之前的调用中提供给它的数据来启用该属性,以便向 GPU 上的顶点着色器执行系统提供位置数据。
下一步是在main
函数中使用这些类来渲染一个三角形到你的窗口。清单 14-6 包含了实现这一点的程序的完整清单。
清单 14-6 。渲染三角形的程序
#include "GL/glew.h"
#include "GLFW/glfw3.h"
#include <string>
#include <vector>
using namespace std;
class Geometry
{
public:
using Vertices = vector < float >;
using Indices = vector < unsigned short >;
private:
Vertices m_Vertices;
Indices m_Indices;
public:
Geometry() = default;
~Geometry() = default;
void SetVertices(const Vertices& vertices)
{
m_Vertices = vertices;
}
Vertices::size_type GetNumVertices() const
{
return m_Vertices.size();
}
Vertices::const_pointer GetVertices() const
{
return m_Vertices.data();
}
void SetIndices(const Indices& indices)
{
m_Indices = indices;
}
Indices::size_type GetNumIndices() const
{
return m_Indices.size();
}
Indices::const_pointer GetIndices() const
{
return m_Indices.data();
}
};
class Shader
{
private:
void LoadShader(GLuint id, const std::string& shaderCode)
{
const unsigned int NUM_SHADERS{ 1 };
const char* pCode{ shaderCode.c_str() };
GLint length{ static_cast<GLint>(shaderCode.length()) };
glShaderSource(id, NUM_SHADERS, &pCode, &length);
glCompileShader(id);
glAttachShader(m_ProgramId, id);
}
protected:
GLuint m_VertexShaderId{ GL_INVALID_VALUE };
GLuint m_FragmentShaderId{ GL_INVALID_VALUE };
GLint m_ProgramId{ GL_INVALID_VALUE };
std::string m_VertexShaderCode;
std::string m_FragmentShaderCode;
public:
Shader() = default;
virtual ~Shader() = default;
virtual void Link()
{
m_ProgramId = glCreateProgram();
m_VertexShaderId = glCreateShader(GL_VERTEX_SHADER);
LoadShader(m_VertexShaderId, m_VertexShaderCode);
m_FragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
LoadShader(m_FragmentShaderId, m_FragmentShaderCode);
glLinkProgram(m_ProgramId);
}
virtual void Setup(const Geometry& geometry)
{
glUseProgram(m_ProgramId);
}
};
class BasicShader
: public Shader
{
private:
GLint m_PositionAttributeHandle;
public:
BasicShader()
{
m_VertexShaderCode =
"attribute vec4 a_vPosition; \n"
"void main(){ \n"
" gl_Position = a_vPosition; \n"
"} \n";
m_FragmentShaderCode =
"#version 150 \n"
"precision mediump float; \n"
"void main(){ \n"
" gl_FragColor = vec4(0.2, 0.2, 0.2, 1.0); \n"
"} \n";
}
~BasicShader() override = default;
void Link() override
{
Shader::Link();
m_PositionAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vPosition");
}
void Setup(const Geometry& geometry) override
{
Shader::Setup(geometry);
glVertexAttribPointer(
m_PositionAttributeHandle,
3,
GL_FLOAT,
GL_FALSE,
0,
geometry.GetVertices());
glEnableVertexAttribArray(m_PositionAttributeHandle);
}
};
int CALLBACK WinMain(
_In_ HINSTANCE hInstance,
_In_ HINSTANCE hPrevInstance,
_In_ LPSTR lpCmdLine,
_In_ int nCmdShow
)
{
GLFWwindow* window;
// Initialize the library
if (!glfwInit())
{
return -1;
}
// Create a windowed mode window and its OpenGL context
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
// Make the window's context current
glfwMakeContextCurrent(window);
GLenum glewError{ glewInit() };
if (glewError != GLEW_OK)
{
return -1;
}
BasicShader basicShader;
basicShader.Link();
Geometry triangle;
Geometry::Vertices vertices{
0.0f, 0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
-0.5f, -0.5f, 0.0f
};
Geometry::Indices indices{ 0, 1, 2 };
triangle.SetVertices(vertices);
triangle.SetIndices(indices);
glClearColor(0.25f, 0.25f, 0.95f, 1.0f);
// Loop until the user closes the window
while (!glfwWindowShouldClose(window))
{
glClear(GL_COLOR_BUFFER_BIT);
basicShader.Setup(triangle);
glDrawElements(GL_TRIANGLES,
triangle.GetNumIndices(),
GL_UNSIGNED_SHORT,
triangle.GetIndices());
// Swap front and back buffers
glfwSwapBuffers(window);
// Poll for and process events
glfwPollEvents();
}
glfwTerminate();
return 0;
}
清单 14-6 中的main
函数展示了如何以及在哪里使用Geometry
和BasicShader
类来渲染一个三角形到你的窗口。在对glewInit
的调用成功完成后,OpenGL API 立即可用。在这个调用之后,main
函数初始化一个BasicShader
对象,然后调用BasicShader::Link
和Geometry
对象来表示一个三角形的顶点。顶点以后变换状态提供,因为BasicShader
中的顶点着色器不对传递的数据执行任何操作。顶点在标准化的设备坐标中指定;在 OpenGL 中,这些坐标必须放在一个立方体中,对于 x、y 和 z 坐标,立方体的范围从-1、-1、-1 到 1,1,1。索引告诉 OpenGL 将顶点传递给顶点着色器的顺序;在这种情况下,您将按照定义的顺序传递顶点。
glClearColor
函数告诉 OpenGL 当没有其他像素被渲染到该位置时,用来表示背景颜色的颜色。这里颜色被设置为浅蓝色,所以很容易判断像素何时被渲染。在 OpenGL 中,颜色由四个部分表示:红色、绿色、蓝色和 alpha。红色、绿色和蓝色分量组合起来生成像素的颜色。当所有分量值都为 1 时,颜色为白色;当所有值都为 0 时,颜色为黑色。alpha 分量用于确定像素的透明度。几乎没有理由将背景色的透明度值设置为小于 1。
你可以在渲染循环中找到对glClear
的调用。该调用使用由glClearColor
设置的值来填充framebuffer
,并覆盖上次使用该缓冲区时呈现的任何内容。请记住,当您使用双缓冲时,您渲染到的缓冲是两帧之前的,而不是一帧。BasicShader::Setup
函数用当前几何图形设置着色器进行渲染。在这个程序中,这可能是一次性的操作,但是对于程序来说,用给定的着色器渲染多个对象更为常见。
最后,glDrawElements
函数负责要求 OpenGL 渲染三角形。glDrawElements 调用指定您想要呈现三角形图元、要呈现的索引数量、索引类型以及指向索引数据流的指针。
图 14-1 显示了该程序生成的输出。
图 14-1 。清单 14-6 中的代码呈现的三角形
14-3.创建一个纹理四边形
问题
GPU 的能力是有限的,你想给你的对象一个更详细的外观。
解决办法
纹理映射允许您创建 2D 图像,您可以在网格表面上映射这些图像,以增加几何复杂性的外观。
它是如何工作的
GLSL 提供了对采样器的支持,可以用来从指定的纹理中读取纹理元素。一个纹理元素 是来自纹理的单一颜色元素;这个术语是纹理元素的简称,就像像素是图片元素的简称一样。术语像素 通常用于指构成显示器上图像的单个颜色,而纹理像素用于指纹理图像中的单个颜色。
使用纹理坐标将纹理映射到网格。网格中的每个顶点都有一个关联的纹理坐标,您可以使用该坐标在片段着色器中查找要应用于片段的颜色。使用 GPU 上的插值器单元将每个顶点的纹理坐标插值到多边形的表面上。要从顶点着色器传递到片段着色器的插值在 OpenGL 中使用varying
关键字表示。该关键字具有逻辑意义,因为varying
用于表示在多边形表面上变化的变量。Varying
在顶点着色器中,通过从属性分配或由代码生成来初始化。
您需要一种方法来表示包含纹理坐标的网格数据,然后才能考虑在应用程序中使用纹理。清单 14-7 显示了支持顶点数据中纹理坐标的Geometry
类的定义。
清单 14-7 。一个支持纹理坐标的类
class Geometry
{
public:
using Vertices = vector < float >;
using Indices = vector < unsigned short >;
private:
Vertices m_Vertices;
Indices m_Indices;
unsigned int m_NumVertexPositionElements{};
unsigned int m_NumTextureCoordElements{};
unsigned int m_VertexStride{};
public:
Geometry() = default;
~Geometry() = default;
void SetVertices(const Vertices& vertices)
{
m_Vertices = vertices;
}
Vertices::size_type GetNumVertices() const
{
return m_Vertices.size();
}
Vertices::const_pointer GetVertices() const
{
return m_Vertices.data();
}
void SetIndices(const Indices& indices)
{
m_Indices = indices;
}
Indices::size_type GetNumIndices() const
{
return m_Indices.size();
}
Indices::const_pointer GetIndices() const
{
return m_Indices.data();
}
Vertices::const_pointer GetTexCoords() const
{
return static_cast<Vertices::const_pointer>(&m_Vertices[m_NumVertexPositionElements]);
}
void SetNumVertexPositionElements(unsigned int numVertexPositionElements)
{
m_NumVertexPositionElements = numVertexPositionElements;
}
unsigned int GetNumVertexPositionElements() const
{
return m_NumVertexPositionElements;
}
void SetNumTexCoordElements(unsigned int numTexCoordElements)
{
m_NumTextureCoordElements = numTexCoordElements;
}
unsigned int GetNumTexCoordElements() const
{
return m_NumTextureCoordElements;
}
void SetVertexStride(unsigned int vertexStride)
{
m_VertexStride = vertexStride;
}
unsigned int GetVertexStride() const
{
return m_VertexStride;
}
};
这段代码显示了在单独的vectors
中存储顶点和索引的Geometry
类的定义。还有存储顶点位置元素的数量和纹理坐标元素的数量的字段。单个顶点可以由可变数量的顶点元素和可变数量的纹理坐标组成。m_VertexStride
字段存储从一个顶点开始到下一个顶点开始的字节数。GetTexCoords
方法是这个类中比较重要的方法之一,因为它表明这个类支持的顶点数据是一个结构数组格式。读入顶点数据有两种主要方法:可以为单独数组中的顶点和纹理坐标设置单独的流,也可以设置一个单独的流来交错每个顶点的顶点位置和纹理坐标数据。这个类支持后一种风格,因为这是现代 GPU 的最佳数据格式。GetTexCoords
方法使用m_NumVertexPositionElements
作为查找数据的索引返回第一个纹理坐标的地址。这依赖于你的网格数据被紧密打包,并且你的第一个纹理坐标紧跟在顶点位置元素之后。
使用 OpenGL 渲染纹理对象时的下一个重要元素是一个可以从文件中加载纹理数据的类。TGA 文件格式简单易用,可以存储图像数据。它的简单性意味着当使用 OpenGL 时,它是未压缩纹理的一种常见的文件格式选择。清单 14-8 中的TGAFile
类展示了如何加载一个 TGA 文件。
清单 14-8 。TGAFile Class
class TGAFile
{
private:
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
struct TGAHeader
{
unsigned char m_IdSize{};
unsigned char m_ColorMapType{};
unsigned char m_ImageType{};
unsigned short m_PaletteStart{};
unsigned short m_PaletteLength{};
unsigned char m_PaletteBits{};
unsigned short m_XOrigin{};
unsigned short m_YOrigin{};
unsigned short m_Width{};
unsigned short m_Height{};
unsigned char m_BytesPerPixel{};
unsigned char m_Descriptor{};
}
#ifndef _MSC_VER
__attribute__ ((packed))
#endif // _MSC_VER
;
#ifdef _MSC_VER
#pragma pack(pop)
#endif
std::vector<char> m_FileData;
TGAHeader* m_pHeader{};
void* m_pImageData{};
public:
TGAFile(const std::string& filename)
{
std::ifstream fileStream{ filename, std::ios_base::binary };
if (fileStream.is_open())
{
fileStream.seekg(0, std::ios::end);
m_FileData.resize(static_cast<unsigned int>(fileStream.tellg()));
fileStream.seekg(0, std::ios::beg);
fileStream.read(m_FileData.data(), m_FileData.size());
fileStream.close();
m_pHeader = reinterpret_cast<TGAHeader*>(m_FileData.data());
m_pImageData = static_cast<void*>(m_FileData.data() + sizeof(TGAHeader));
}
}
unsigned short GetWidth() const
{
return m_pHeader->m_Width;
}
unsigned short GetHeight() const
{
return m_pHeader->m_Height;
}
unsigned char GetBytesPerPixel() const
{
return m_pHeader->m_BytesPerPixel;
}
unsigned int GetDataSize() const
{
return m_FileData.size() - sizeof(TGAHeader);
}
void* GetImageData() const
{
return m_pImageData;
}
};
TGAFile
类包含一个 header 结构,它表示由图像编辑程序(如 Adobe Photoshop)保存时 TGA 文件中包含的标题数据。这个结构有一些与之相关的有趣的编译器元数据。现代 C++ 编译器知道应用程序中数据结构的内存布局。给定的 CPU 架构可以更有效地操作位于特定存储器边界上的变量。这对于不可移植且在单 CPU 架构上的单个程序中使用的结构来说没问题,但是对于由不同计算机上的不同程序保存和加载的数据来说,这可能会导致问题。为了抵消这一点,您可以指定编译器可以添加到程序中的填充量,以优化对单个变量的访问。TGAHeader
结构要求不添加任何填充,因为保存文件时 TGA 文件格式不包含任何填充。这是在使用 Visual Studio 时通过使用pragma
预处理器指令和pack
命令将push
和pop
的打包值设为 1 来实现的。这将禁用变量的自动间距以提高速度效率。在大多数其他编译器上,您可以使用__attribute__ ((packed))
编译器指令来获得相同的结果。
TGAHeader
字段存储代表存储在文件中的图像数据类型的元数据。这个方法只处理 TGA 中的 RGBA 数据,所以唯一相关的字段是宽度、高度和每像素字节数。这些可以在文件中的TGAHeader
结构中表示的精确字节位置中找到。通过使用指针,文件中的数据被映射到TGAHeader
对象中。文件名被传递给该类的构造函数,然后使用一个ifstream
对象打开并读取该文件。ifstream
对象是为从文件中读入数据而提供的 STL 类。通过向其传递要打开的文件名和二进制数据模式来构造ifstream
,因为您想要从文件中读取二进制数据。整个文件被读入一个由char
个变量组成的向量,方法是查找到文件的末尾,读取文件末尾的位置以确定文件中数据的大小,然后返回到开头并使用大小来调整向量的大小。然后通过使用ifstream read
方法将数据读入向量,该方法获取一个指向应该读取数据的缓冲区的指针和要读取的缓冲区的大小。然后,您可以使用reinterpret_cast
将从文件中读取的数据映射到一个TGAHeader
结构上,并且可以使用一个static_cast
来存储指向图像数据开头的指针。
通过使用单独的类,加载 TGA 数据与 OpenGL 纹理设置是分开的。从 TGA 加载的数据可以传递给列表 14-9 中所示的纹理类来创建一个 OpenGL 纹理对象。
清单 14-9 。Texture Class
class Texture
{
private:
unsigned int m_Width{};
unsigned int m_Height{};
unsigned int m_BytesPerPixel{};
unsigned int m_DataSize{};
GLuint m_Id{};
void* m_pImageData;
public:
Texture(const TGAFile& tgaFile)
: Texture(tgaFile.GetWidth(),
tgaFile.GetHeight(),
tgaFile.GetBytesPerPixel(),
tgaFile.GetDataSize(),
tgaFile.GetImageData())
{
}
Texture(unsigned int width,
unsigned int height,
unsigned int bytesPerPixel,
unsigned int dataSize,
void* pImageData)
: m_Width(width)
, m_Height(height)
, m_BytesPerPixel(bytesPerPixel)
, m_DataSize(dataSize)
, m_pImageData(pImageData)
{
}
~Texture() = default;
GLuint GetId() const
{
return m_Id;
}
void Init()
{
GLint packBits{ 4 };
GLint internalFormat{ GL_RGBA };
GLint format{ GL_BGRA };
glGenTextures(1, &m_Id);
glBindTexture(GL_TEXTURE_2D, m_Id);
glPixelStorei(GL_UNPACK_ALIGNMENT, packBits);
glTexImage2D(GL_TEXTURE_2D,
0,
internalFormat,
m_Width,
m_Height,
0,
format,
GL_UNSIGNED_BYTE,
m_pImageData);
}
};
类初始化一个 OpenGL 纹理,在渲染对象时使用。提供这两个类构造函数是为了简化从 TGA 文件或内存数据初始化类。采用TGAFile
引用的构造函数使用 C++11 委托构造函数的概念来调用内存中的构造函数。Init
方法负责创建 OpenGL 纹理对象。此方法可以使用构造函数中提供的宽度和高度从 BGRA 源创建 RGBA 纹理。您可能会注意到,TGA 文件中的源像素是前后颠倒的;这种方法负责将红色和绿色通道转换到 GPU 的正确位置。图像数据被glTextImage2D
函数复制到 GPU 上,这样draw
调用可以在你的片段着色器中使用这些纹理数据。
能够使用纹理进行渲染的下一步是查看TextureShader
类,它包括一个顶点着色器,可以读入纹理坐标,并通过一个变化的对象将它们传递给片段着色器。你可以在的清单 14-10 中看到这个类。
清单 14-10 。TextureShader Class
class Shader
{
private:
void LoadShader(GLuint id, const std::string& shaderCode)
{
const unsigned int NUM_SHADERS{ 1 };
const char* pCode{ shaderCode.c_str() };
GLint length{ static_cast<GLint>(shaderCode.length()) };
glShaderSource(id, NUM_SHADERS, &pCode, &length);
glCompileShader(id);
glAttachShader(m_ProgramId, id);
}
protected:
GLuint m_VertexShaderId{ GL_INVALID_VALUE };
GLuint m_FragmentShaderId{ GL_INVALID_VALUE };
GLint m_ProgramId{ GL_INVALID_VALUE };
std::string m_VertexShaderCode;
std::string m_FragmentShaderCode;
public:
Shader() = default;
virtual ~Shader() = default;
virtual void Link()
{
m_ProgramId = glCreateProgram();
m_VertexShaderId = glCreateShader(GL_VERTEX_SHADER);
LoadShader(m_VertexShaderId, m_VertexShaderCode);
m_FragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
LoadShader(m_FragmentShaderId, m_FragmentShaderCode);
glLinkProgram(m_ProgramId);
}
virtual void Setup(const Geometry& geometry)
{
glUseProgram(m_ProgramId);
}
};
class TextureShader
: public Shader
{
private:
const Texture& m_Texture;
GLint m_PositionAttributeHandle;
GLint m_TextureCoordinateAttributeHandle;
GLint m_SamplerHandle;
public:
TextureShader(const Texture& texture)
: m_Texture(texture)
{
m_VertexShaderCode =
"attribute vec4 a_vPosition; \n"
"attribute vec2 a_vTexCoord; \n"
"varying vec2 v_vTexCoord; \n"
" \n"
"void main() { \n"
" gl_Position = a_vPosition; \n"
" v_vTexCoord = a_vTexCoord; \n"
"} \n";
m_FragmentShaderCode =
"#version 150 \n"
" \n"
"precision highp float; \n"
"varying vec2 v_vTexCoord; \n"
"uniform sampler2D s_2dTexture; \n"
" \n"
"void main() { \n"
" gl_FragColor = \n"
" texture2D(s_2dTexture, v_vTexCoord); \n"
"} \n";
}
~TextureShader() override = default;
void Link() override
{
Shader::Link();
m_PositionAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vPosition");
m_TextureCoordinateAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vTexCoord");
m_SamplerHandle = glGetUniformLocation(m_ProgramId, "s_2dTexture");
}
void Setup(const Geometry& geometry) override
{
Shader::Setup(geometry);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_Texture.GetId());
glUniform1i(m_SamplerHandle, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glVertexAttribPointer(
m_PositionAttributeHandle,
geometry.GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
geometry.GetVertexStride(),
geometry.GetVertices());
glEnableVertexAttribArray(m_PositionAttributeHandle);
glVertexAttribPointer(
m_TextureCoordinateAttributeHandle,
geometry.GetNumTexCoordElements(),
GL_FLOAT,
GL_FALSE,
geometry.GetVertexStride(),
geometry.GetTexCoords());
glEnableVertexAttribArray(m_TextureCoordinateAttributeHandle);
}
};
TextureShader class
继承自Shader
类。TextureShader class
构造函数中的顶点着色器代码包含两个属性和一个变量。顶点的位置元素被直接传递给内置的gl_Position
变量,该变量接收顶点的最终转换位置。a_vTexCoord
属性被传递给v_vTexCoord
变量。变量用于将插值数据从顶点着色器传输到片段着色器,因此顶点着色器和片段着色器都包含具有相同类型和名称的变量非常重要。OpenGL 在幕后工作,以确保来自顶点着色器的不同输出被传递到片段着色器中的相同输出。
片段着色器包含一个制服。统一更像着色器常量,因为它们由每个绘制调用的单个调用来设置,并且着色器的每个实例都接收相同的值。在这种情况下,片段着色器的每个实例都接收相同的采样器 ID,以从相同的纹理中检索数据。使用texture2D
函数读取该数据,该函数采用统一的sampler2D
和变化的v_vTexCoord
。变化的纹理坐标已经被内插在一个多边形的表面上,所以该多边形是使用来自纹理数据的不同纹理元素来映射的。
在每次调用draw
之前,TextureShader::Setup
函数负责初始化采样器状态。使用glActiveTexture
功能初始化你想要使用的纹理单元。使用glBindTexture
将一个纹理绑定到这个纹理单元,传递给它的是 OpenGL 纹理的 ID。统一绑定有些不直观。glActiveTexture
接收常量值GL_TEXTURE0
作为值,而不是 0。这允许glActiveTexture
调用将纹理与纹理图像单元绑定相关联,但是片段着色器不使用相同的值;相反,它使用纹理图像单元的索引。在这种情况下,GL_TEXTURE0
可以在索引 0 处找到,因此值 0 被绑定到片段着色器中的m_SamplerHandle
统一。
然后为绑定纹理初始化采样器参数。它们被设置为在两个方向上夹紧纹理。如果您想要使用纹理坐标的正常范围 0 到 1 之外的值,这将非常有用。在这些情况下,也可以设置纹理来包裹、重复或镜像。接下来的两个选项配置当纹理在屏幕上缩小或放大时的采样设置。当纹理被应用到一个比一对一映射的纹理占用更少屏幕空间的对象时,就会发生缩小。在屏幕上以 256 × 256 渲染 512 × 512 的纹理时,可能会出现这种情况。放大发生在相反的情况下,其中纹理被渲染到一个对象,该对象占用的屏幕空间比纹理提供的纹理像素多。线性映射使用最接近采样点的四个纹理元素来计算要应用于片段的颜色的平均值。这会以稍微模糊纹理为代价,使纹理看起来不那么块状。根据应用到纹理的缩小或放大程度,效果会更明显。
然后,TextureShader::Setup
函数为顶点着色器的属性字段初始化数据流。使用来自几何对象的位置元素的数量以及来自该位置的步幅,将顶点位置元素绑定到m_PositionAttributeHandle
位置。属性初始化后,通过调用glEnableVertexAttribArray
来启用它。m_TextureCoordinateAttributeHandle
属性使用相同的函数初始化,但使用不同的数据。从geometry
对象中获取每个顶点的纹理元素数量,纹理坐标流也是如此。顶点数据和纹理数据的跨度保持不变,因为它们以结构数组格式打包到同一个流中。
清单 14-11 中的代码将所有这些结合在一起,并添加了一个main
函数来展示如何初始化一个纹理和几何体,以将一个四边形渲染到应用了纹理图像的屏幕上。
清单 14-11 。纹理四边形程序
#include "GL/glew.h"
#include "GLFW/glfw3.h"
#include <string>
#include <vector>
using namespace std;
class Geometry
{
public:
using Vertices = vector < float >;
using Indices = vector < unsigned short >;
private:
Vertices m_Vertices;
Indices m_Indices;
unsigned int m_NumVertexPositionElements{};
unsigned int m_NumTextureCoordElements{};
unsigned int m_VertexStride{};
public:
Geometry() = default;
~Geometry() = default;
void SetVertices(const Vertices& vertices)
{
m_Vertices = vertices;
}
Vertices::size_type GetNumVertices() const
{
return m_Vertices.size();
}
Vertices::const_pointer GetVertices() const
{
return m_Vertices.data();
}
void SetIndices(const Indices& indices)
{
m_Indices = indices;
}
Indices::size_type GetNumIndices() const
{
return m_Indices.size();
}
Indices::const_pointer GetIndices() const
{
return m_Indices.data();
}
Vertices::const_pointer GetTexCoords() const
{
return static_cast<Vertices::const_pointer>(&m_Vertices
[m_NumVertexPositionElements]);
}
void SetNumVertexPositionElements(unsigned int numVertexPositionElements)
{
m_NumVertexPositionElements = numVertexPositionElements;
}
unsigned int GetNumVertexPositionElements() const
{
return m_NumVertexPositionElements;
}
void SetNumTexCoordElements(unsigned int numTexCoordElements)
{
m_NumTextureCoordElements = numTexCoordElements;
}
unsigned int GetNumTexCoordElements() const
{
return m_NumTextureCoordElements;
}
void SetVertexStride(unsigned int vertexStride)
{
m_VertexStride = vertexStride;
}
unsigned int GetVertexStride() const
{
return m_VertexStride;
}
};
class TGAFile
{
private:
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
struct TGAHeader
{
unsigned char m_IdSize{};
unsigned char m_ColorMapType{};
unsigned char m_ImageType{};
unsigned short m_PaletteStart{};
unsigned short m_PaletteLength{};
unsigned char m_PaletteBits{};
unsigned short m_XOrigin{};
unsigned short m_YOrigin{};
unsigned short m_Width{};
unsigned short m_Height{};
unsigned char m_BytesPerPixel{};
unsigned char m_Descriptor{};
}
#ifndef _MSC_VER
__attribute__ ((packed))
#endif // _MSC_VER
;
#ifdef _MSC_VER
#pragma pack(pop)
#endif
std::vector<char> m_FileData;
TGAHeader* m_pHeader{};
void* m_pImageData{};
public:
TGAFile(const std::string& filename)
{
std::ifstream fileStream{ filename, std::ios_base::binary };
if (fileStream.is_open())
{
fileStream.seekg(0, std::ios::end);
m_FileData.resize(static_cast<unsigned int>(fileStream.tellg()));
fileStream.seekg(0, std::ios::beg);
fileStream.read(m_FileData.data(), m_FileData.size());
fileStream.close();
m_pHeader = reinterpret_cast<TGAHeader*>(m_FileData.data());
m_pImageData = static_cast<void*>(m_FileData.data() + sizeof(TGAHeader));
}
}
unsigned short GetWidth() const
{
return m_pHeader->m_Width;
}
unsigned short GetHeight() const
{
return m_pHeader->m_Height;
}
unsigned char GetBytesPerPixel() const
{
return m_pHeader->m_BytesPerPixel;
}
unsigned int GetDataSize() const
{
return m_FileData.size() - sizeof(TGAHeader);
}
void* GetImageData() const
{
return m_pImageData;
}
};
class Texture
{
private:
unsigned int m_Width{};
unsigned int m_Height{};
unsigned int m_BytesPerPixel{};
unsigned int m_DataSize{};
GLuint m_Id{};
void* m_pImageData;
public:
Texture(const TGAFile& tgaFile)
: Texture(tgaFile.GetWidth(),
tgaFile.GetHeight(),
tgaFile.GetBytesPerPixel(),
tgaFile.GetDataSize(),
tgaFile.GetImageData())
{
}
Texture(unsigned int width,
unsigned int height,
unsigned int bytesPerPixel,
unsigned int dataSize,
void* pImageData)
: m_Width(width)
, m_Height(height)
, m_BytesPerPixel(bytesPerPixel)
, m_DataSize(dataSize)
, m_pImageData(pImageData)
{
}
~Texture() = default;
GLuint GetId() const
{
return m_Id;
}
void Init()
{
GLint packBits{ 4 };
GLint internalFormat{ GL_RGBA };
GLint format{ GL_BGRA };
glGenTextures(1, &m_Id);
glBindTexture(GL_TEXTURE_2D, m_Id);
glPixelStorei(GL_UNPACK_ALIGNMENT, packBits);
glTexImage2D(GL_TEXTURE_2D,
0,
internalFormat,
m_Width,
m_Height,
0,
format,
GL_UNSIGNED_BYTE,
m_pImageData);
}
};
class Shader
{
private:
void LoadShader(GLuint id, const std::string& shaderCode)
{
const unsigned int NUM_SHADERS{ 1 };
const char* pCode{ shaderCode.c_str() };
GLint length{ static_cast<GLint>(shaderCode.length()) };
glShaderSource(id, NUM_SHADERS, &pCode, &length);
glCompileShader(id);
glAttachShader(m_ProgramId, id);
}
protected:
GLuint m_VertexShaderId{ GL_INVALID_VALUE };
GLuint m_FragmentShaderId{ GL_INVALID_VALUE };
GLint m_ProgramId{ GL_INVALID_VALUE };
std::string m_VertexShaderCode;
std::string m_FragmentShaderCode;
public:
Shader() = default;
virtual ~Shader() = default;
virtual void Link()
{
m_ProgramId = glCreateProgram();
m_VertexShaderId = glCreateShader(GL_VERTEX_SHADER);
LoadShader(m_VertexShaderId, m_VertexShaderCode);
m_FragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
LoadShader(m_FragmentShaderId, m_FragmentShaderCode);
glLinkProgram(m_ProgramId);
}
virtual void Setup(const Geometry& geometry)
{
glUseProgram(m_ProgramId);
}
};
class TextureShader
: public Shader
{
private:
const Texture& m_Texture;
GLint m_PositionAttributeHandle;
GLint m_TextureCoordinateAttributeHandle;
GLint m_SamplerHandle;
public:
TextureShader(const Texture& texture)
: m_Texture(texture)
{
m_VertexShaderCode =
"attribute vec4 a_vPosition; \n"
"attribute vec2 a_vTexCoord; \n"
"varying vec2 v_vTexCoord; \n"
" \n"
"void main() { \n"
" gl_Position = a_vPosition; \n"
" v_vTexCoord = a_vTexCoord; \n"
"} \n";
m_FragmentShaderCode =
"#version 150 \n"
" \n"
"precision highp float; \n"
"varying vec2 v_vTexCoord; \n"
"uniform sampler2D s_2dTexture; \n"
" \n"
"void main() { \n"
" gl_FragColor = \n"
" texture2D(s_2dTexture, v_vTexCoord); \n"
"} \n";
}
~TextureShader() override = default;
void Link() override
{
Shader::Link();
m_PositionAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vPosition");
m_TextureCoordinateAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vTexCoord");
m_SamplerHandle = glGetUniformLocation(m_ProgramId, "s_2dTexture");
}
void Setup(const Geometry& geometry) override
{
Shader::Setup(geometry);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_Texture.GetId());
glUniform1i(m_SamplerHandle, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glVertexAttribPointer(
m_PositionAttributeHandle,
geometry.GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
geometry.GetVertexStride(),
geometry.GetVertices());
glEnableVertexAttribArray(m_PositionAttributeHandle);
glVertexAttribPointer(
m_TextureCoordinateAttributeHandle,
geometry.GetNumTexCoordElements(),
GL_FLOAT,
GL_FALSE,
geometry.GetVertexStride(),
geometry.GetTexCoords());
glEnableVertexAttribArray(m_TextureCoordinateAttributeHandle);
}
};
int CALLBACK WinMain(
_In_ HINSTANCE hInstance,
_In_ HINSTANCE hPrevInstance,
_In_ LPSTR lpCmdLine,
_In_ int nCmdShow
)
{
GLFWwindow* window;
// Initialize the library
if (!glfwInit())
{
return -1;
}
// Create a windowed mode window and its OpenGL context
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
// Make the window's context current
glfwMakeContextCurrent(window);
GLenum glewError{ glewInit() };
if (glewError != GLEW_OK)
{
return -1;
}
TGAFile myTextureFile("MyTexture.tga");
Texture myTexture(myTextureFile);
myTexture.Init();
TextureShader textureShader(myTexture);
textureShader.Link();
Geometry quad;
Geometry::Vertices vertices{
-0.5f, 0.5f, 0.0f,
0.0f, 1.0f,
0.5f, 0.5f, 0.0f,
1.0f, 1.0f,
-0.5f, -0.5f, 0.0f,
0.0f, 0.0f,
0.5f, -0.5f, 0.0f,
1.0f, 0.0f
};
Geometry::Indices indices{ 0, 2, 1, 2, 3, 1 };
quad.SetVertices(vertices);
quad.SetIndices(indices);
quad.SetNumVertexPositionElements(3);
quad.SetNumTexCoordElements(2);
quad.SetVertexStride(sizeof(float) * 5);
glClearColor(0.25f, 0.25f, 0.95f, 1.0f);
// Loop until the user closes the window
while (!glfwWindowShouldClose(window))
{
glClear(GL_COLOR_BUFFER_BIT);
textureShader.Setup(quad);
glDrawElements(GL_TRIANGLES,
quad.GetNumIndices(),
GL_UNSIGNED_SHORT,
quad.GetIndices());
// Swap front and back buffers
glfwSwapBuffers(window);
// Poll for and process events
glfwPollEvents();
}
glfwTerminate();
return 0;
}
清单 14-11 中程序的完整源代码展示了如何将这个配方中引入的所有类组合在一起,以渲染一个纹理四边形。初始化TGAFile
类以加载MyTexture.tga
文件。这被传递给类型为Texture
的myTexture
对象。调用Texture::Init
函数来初始化 OpenGL texture
对象。初始化的纹理又被传递给TextureShader
类的一个实例,该实例创建、初始化并链接一个 OpenGL 着色器程序,该程序可用于渲染 2D 纹理几何图形。然后创建几何图形;指定的顶点包括每个顶点的三个位置元素和两个纹理坐标元素。OpenGL 使用四个顶点和六个索引来渲染由两个三角形组成的四边形。索引 1 和 2 处的顶点由两个三角形共享;您可以看到如何使用索引来减少网格所需的几何体定义。这里还有另一个优化优势:许多现代 CPU 缓存已经处理过的顶点的结果,因此您可以从缓存中读取重用的顶点数据,而不是让 GPU 重新处理它。
所有设置工作完成后,实际的渲染是微不足道的。有清除帧缓冲区、设置着色器、绘制元素、交换缓冲区和轮询操作系统事件的调用。图 14-2 显示了当一切都完成并正常工作时,这个程序的输出是什么样子。
图 14-2 。显示使用 OpenGL 渲染的纹理四边形的输出
14-4.从文件加载几何图形
问题
您希望能够从您团队中的艺术家创建的文件中加载网格数据。
解决办法
C++ 允许你编写可以加载许多不同文件格式的代码。这个食谱告诉你如何加载波前.obj
文件。
它是如何工作的
.obj
文件格式最初由 Wavefront Technologies 开发。它可以从许多 3D 建模程序中导出,并且是一种简单的基于文本的格式,使其成为学习如何导入 3D 数据的理想媒介。清单 14-12 中的OBJFile
类 展示了如何从一个源文件加载一个.obj
文件。
清单 14-12 。加载一个.obj
文件
class OBJFile
{
public:
using Vertices = vector < float > ;
using TextureCoordinates = vector < float > ;
using Normals = vector < float > ;
using Indices = vector < unsigned short > ;
private:
Vertices m_VertexPositions;
TextureCoordinates m_TextureCoordinates;
Normals m_Normals;
Indices m_Indices;
public:
OBJFile(const std::string& filename)
{
std::ifstream fileStream{ filename, std::ios_base::in };
if (fileStream.is_open())
{
while (!fileStream.eof())
{
std::string line;
getline(fileStream, line);
stringstream lineStream{ line };
std::string firstSymbol;
lineStream >> firstSymbol;
if (firstSymbol == "v")
{
float vertexPosition{};
for (unsigned int i = 0; i < 3; ++i)
{
lineStream >> vertexPosition;
m_VertexPositions.emplace_back(vertexPosition);
}
}
else if (firstSymbol == "vt")
{
float textureCoordinate{};
for (unsigned int i = 0; i < 2; ++i)
{
lineStream >> textureCoordinate;
m_TextureCoordinates.emplace_back(textureCoordinate);
}
}
else if (firstSymbol == "vn")
{
float normal{};
for (unsigned int i = 0; i < 3; ++i)
{
lineStream >> normal;
m_Normals.emplace_back(normal);
}
}
else if (firstSymbol == "f")
{
char separator;
unsigned short index{};
for (unsigned int i = 0; i < 3; ++i)
{
for (unsigned int j = 0; j < 3; ++j)
{
lineStream >> index;
m_Indices.emplace_back(index);
if (j < 2)
{
lineStream >> separator;
}
}
}
}
}
}
}
const Vertices& GetVertices() const
{
return m_VertexPositions;
}
const TextureCoordinates& GetTextureCoordinates() const
{
return m_TextureCoordinates;
}
const Normals& GetNormals() const
{
return m_Normals;
}
const Indices& GetIndices() const
{
return m_Indices;
}
};
这段代码展示了如何从一个.obj
文件中读取数据。.obj
数据按行存储。代表顶点位置的线以字母 v 开始,包含三个浮点数,代表顶点的 x、y 和 z 位移。以 vt 开头的一行包含一个纹理坐标,两个浮点数代表纹理坐标的 u 和 v 分量。 vn 线代表顶点法线,包含顶点法线的 x、y 和 z 分量。您感兴趣的最后一种线条以 n 开头,代表三角形的索引。每个顶点在面中用三个数字表示:顶点位置列表的索引、纹理坐标的索引和顶点法线的索引。所有这些数据都被加载到类中的四个向量中;有从类中检索数据的访问器。清单 14-13 中的Geometry
类有一个构造函数,它可以引用一个OBJFile
对象并创建一个 OpenGL 可以渲染的网格。
清单 14-13 。Geometry
类
class Geometry
{
public:
using Vertices = vector < float >;
using Indices = vector < unsigned short >;
private:
Vertices m_Vertices;
Indices m_Indices;
unsigned int m_NumVertexPositionElements{};
unsigned int m_NumTextureCoordElements{};
unsigned int m_VertexStride{};
public:
Geometry() = default;
Geometry(const OBJFile& objFile)
{
const OBJFile::Indices& objIndices{ objFile.GetIndices() };
const OBJFile::Vertices& objVertexPositions{ objFile.GetVertices() };
const OBJFile::TextureCoordinates& objTextureCoordinates{
objFile.GetTextureCoordinates() };
for (unsigned int i = 0; i < objIndices.size(); i += 3U)
{
m_Indices.emplace_back(i / 3);
const Indices::value_type index{ objIndices[i] - 1U };
const unsigned int vertexPositionIndex{ index * 3U };
m_Vertices.emplace_back(objVertexPositions[vertexPositionIndex]);
m_Vertices.emplace_back(objVertexPositions[vertexPositionIndex+1]);
m_Vertices.emplace_back(objVertexPositions[vertexPositionIndex+2]);
const OBJFile::TextureCoordinates::size_type texCoordObjIndex{
objIndices[i + 1] - 1U };
const unsigned int textureCoodsIndex{ texCoordObjIndex * 2U };
m_Vertices.emplace_back(objTextureCoordinates[textureCoodsIndex]);
m_Vertices.emplace_back(objTextureCoordinates[textureCoodsIndex+1]);
}
}
~Geometry() = default;
void SetVertices(const Vertices& vertices)
{
m_Vertices = vertices;
}
Vertices::size_type GetNumVertices() const
{
return m_Vertices.size();
}
Vertices::const_pointer GetVertices() const
{
return m_Vertices.data();
}
void SetIndices(const Indices& indices)
{
m_Indices = indices;
}
Indices::size_type GetNumIndices() const
{
return m_Indices.size();
}
Indices::const_pointer GetIndices() const
{
return m_Indices.data();
}
Vertices::const_pointer GetTexCoords() const
{
return static_cast<Vertices::const_pointer>(&m_Vertices[m_NumVertexPositionElements]);
}
void SetNumVertexPositionElements(unsigned int numVertexPositionElements)
{
m_NumVertexPositionElements = numVertexPositionElements;
}
unsigned int GetNumVertexPositionElements() const
{
return m_NumVertexPositionElements;
}
void SetNumTexCoordElements(unsigned int numTexCoordElements)
{
m_NumTextureCoordElements = numTexCoordElements;
}
unsigned int GetNumTexCoordElements() const
{
return m_NumTextureCoordElements;
}
void SetVertexStride(unsigned int vertexStride)
{
m_VertexStride = vertexStride;
}
unsigned int GetVertexStride() const
{
return m_VertexStride;
}
};
清单 14-13 包含了一个Geometry
类的构造函数,它可以从一个OBJFile
实例中为 OpenGL 构建几何图形。OBJFile::m_Indices
向量包含每个 OpenGL 顶点的三个索引。这个配方的Geometry
类只关心顶点位置索引和纹理坐标索引,但是for
循环仍然被配置为每次迭代向前跳过三个索引。Geometry
对象的顶点索引是obj
索引除以 3;当前顶点由通过查找在for
循环的每次迭代中获得的给定obj
索引的obj
顶点位置和纹理坐标获得的数据构成。.obj
文件中的顶点索引和纹理坐标索引从 1 而不是 0 开始,所以从每个索引中减去 1 以得到正确的矢量索引。然后,顶点位置索引乘以 3,纹理坐标索引乘以 2,因为从原始.obj
文件读取的每个顶点位置有三个元素,每个纹理坐标有两个元素。在循环结束时,您有一个Geometry
对象,其中包含从文件中加载的顶点和纹理坐标数据。清单 14-14 中的代码展示了如何在程序中使用这些类来渲染一个使用 Blender 3D 建模包创建和导出的纹理球体。
注意这本书里的大部分食谱都是独立的,但是 OpenGL API 涵盖了很多执行看似简单的任务所必需的代码。清单 14-14 包含配方 14-3 中包含的
Texture
、Shader
和TextureShader
类。
清单 14-14 。渲染一个有纹理的球体
#include <cassert>
#include <fstream>
#include "GL/glew.h"
#include "GLFW/glfw3.h"
#include <memory>
#include <sstream>
#include <string>
#include <vector>
using namespace std;
class OBJFile
{
public:
using Vertices = vector < float > ;
using TextureCoordinates = vector < float > ;
using Normals = vector < float > ;
using Indices = vector < unsigned short > ;
private:
Vertices m_VertexPositions;
TextureCoordinates m_TextureCoordinates;
Normals m_Normals;
Indices m_Indices;
public:
OBJFile(const std::string& filename)
{
std::ifstream fileStream{ filename, std::ios_base::in };
if (fileStream.is_open())
{
while (!fileStream.eof())
{
std::string line;
getline(fileStream, line);
stringstream lineStream{ line };
std::string firstSymbol;
lineStream >> firstSymbol;
if (firstSymbol == "v")
{
float vertexPosition{};
for (unsigned int i = 0; i < 3; ++i)
{
lineStream >> vertexPosition;
m_VertexPositions.emplace_back(vertexPosition);
}
}
else if (firstSymbol == "vt")
{
float textureCoordinate{};
for (unsigned int i = 0; i < 2; ++i)
{
lineStream >> textureCoordinate;
m_TextureCoordinates.emplace_back(textureCoordinate);
}
}
else if (firstSymbol == "vn")
{
float normal{};
for (unsigned int i = 0; i < 3; ++i)
{
lineStream >> normal;
m_Normals.emplace_back(normal);
}
}
else if (firstSymbol == "f")
{
char separator;
unsigned short index{};
for (unsigned int i = 0; i < 3; ++i)
{
for (unsigned int j = 0; j < 3; ++j)
{
lineStream >> index;
m_Indices.emplace_back(index);
if (j < 2)
{
lineStream >> separator;
}
}
}
}
}
}
}
const Vertices& GetVertices() const
{
return m_VertexPositions;
}
const TextureCoordinates& GetTextureCoordinates() const
{
return m_TextureCoordinates;
}
const Normals& GetNormals() const
{
return m_Normals;
}
const Indices& GetIndices() const
{
return m_Indices;
}
};
class Geometry
{
public:
using Vertices = vector < float >;
using Indices = vector < unsigned short >;
private:
Vertices m_Vertices;
Indices m_Indices;
unsigned int m_NumVertexPositionElements{};
unsigned int m_NumTextureCoordElements{};
unsigned int m_VertexStride{};
public:
Geometry() = default;
Geometry(const OBJFile& objFile)
{
const OBJFile::Indices& objIndices{ objFile.GetIndices() };
const OBJFile::Vertices& objVertexPositions{ objFile.GetVertices() };
const OBJFile::TextureCoordinates& objTextureCoordinates{
objFile.GetTextureCoordinates() };
for (unsigned int i = 0; i < objIndices.size(); i += 3U)
{
m_Indices.emplace_back(i / 3);
const Indices::value_type index{ objIndices[i] - 1U };
const unsigned int vertexPositionIndex{ index * 3U };
m_Vertices.emplace_back(objVertexPositions[vertexPositionIndex]);
m_Vertices.emplace_back(objVertexPositions[vertexPositionIndex+1]);
m_Vertices.emplace_back(objVertexPositions[vertexPositionIndex+2]);
const OBJFile::TextureCoordinates::size_type texCoordObjIndex{
objIndices[i + 1] - 1U };
const unsigned int textureCoodsIndex{ texCoordObjIndex * 2U };
m_Vertices.emplace_back(objTextureCoordinates[textureCoodsIndex]);
m_Vertices.emplace_back(objTextureCoordinates[textureCoodsIndex+1]);
}
}
~Geometry() = default;
void SetVertices(const Vertices& vertices)
{
m_Vertices = vertices;
}
Vertices::size_type GetNumVertices() const
{
return m_Vertices.size();
}
Vertices::const_pointer GetVertices() const
{
return m_Vertices.data();
}
void SetIndices(const Indices& indices)
{
m_Indices = indices;
}
Indices::size_type GetNumIndices() const
{
return m_Indices.size();
}
Indices::const_pointer GetIndices() const
{
return m_Indices.data();
}
Vertices::const_pointer GetTexCoords() const
{
return static_cast<Vertices::const_pointer>(&m_Vertices[m_NumVertexPositionElements]);
}
void SetNumVertexPositionElements(unsigned int numVertexPositionElements)
{
m_NumVertexPositionElements = numVertexPositionElements;
}
unsigned int GetNumVertexPositionElements() const
{
return m_NumVertexPositionElements;
}
void SetNumTexCoordElements(unsigned int numTexCoordElements)
{
m_NumTextureCoordElements = numTexCoordElements;
}
unsigned int GetNumTexCoordElements() const
{
return m_NumTextureCoordElements;
}
void SetVertexStride(unsigned int vertexStride)
{
m_VertexStride = vertexStride;
}
unsigned int GetVertexStride() const
{
return m_VertexStride;
}
};
class TGAFile
{
private:
#ifdef _MSC_VER
#pragma pack(push, 1)
#endif
struct TGAHeader
{
unsigned char m_IdSize{};
unsigned char m_ColorMapType{};
unsigned char m_ImageType{};
unsigned short m_PaletteStart{};
unsigned short m_PaletteLength{};
unsigned char m_PaletteBits{};
unsigned short m_XOrigin{};
unsigned short m_YOrigin{};
unsigned short m_Width{};
unsigned short m_Height{};
unsigned char m_BytesPerPixel{};
unsigned char m_Descriptor{};
}
#ifndef _MSC_VER
__attribute__ ((packed))
#endif // _MSC_VER
;
#ifdef _MSC_VER
#pragma pack(pop)
#endif
std::vector<char> m_FileData;
TGAHeader* m_pHeader{};
void* m_pImageData{};
public:
TGAFile(const std::string& filename)
{
std::ifstream fileStream{ filename, std::ios_base::binary };
if (fileStream.is_open())
{
fileStream.seekg(0, std::ios::end);
m_FileData.resize(static_cast<unsigned int>(fileStream.tellg()));
fileStream.seekg(0, std::ios::beg);
fileStream.read(m_FileData.data(), m_FileData.size());
fileStream.close();
m_pHeader = reinterpret_cast<TGAHeader*>(m_FileData.data());
m_pImageData = static_cast<void*>(m_FileData.data() + sizeof(TGAHeader));
}
}
unsigned short GetWidth() const
{
return m_pHeader->m_Width;
}
unsigned short GetHeight() const
{
return m_pHeader->m_Height;
}
unsigned char GetBytesPerPixel() const
{
return m_pHeader->m_BytesPerPixel;
}
unsigned int GetDataSize() const
{
return m_FileData.size() - sizeof(TGAHeader);
}
void* GetImageData() const
{
return m_pImageData;
}
};
class Texture
{
private:
unsigned int m_Width{};
unsigned int m_Height{};
unsigned int m_BytesPerPixel{};
unsigned int m_DataSize{};
GLuint m_Id{};
void* m_pImageData;
public:
Texture(const TGAFile& tgaFile)
: Texture(tgaFile.GetWidth(),
tgaFile.GetHeight(),
tgaFile.GetBytesPerPixel(),
tgaFile.GetDataSize(),
tgaFile.GetImageData())
{
}
Texture(unsigned int width,
unsigned int height,
unsigned int bytesPerPixel,
unsigned int dataSize,
void* pImageData)
: m_Width(width)
, m_Height(height)
, m_BytesPerPixel(bytesPerPixel)
, m_DataSize(dataSize)
, m_pImageData(pImageData)
{
}
~Texture() = default;
GLuint GetId() const
{
return m_Id;
}
void Init()
{
GLint packBits{ 4 };
GLint internalFormat{ GL_RGBA };
GLint format{ GL_BGRA };
glGenTextures(1, &m_Id);
glBindTexture(GL_TEXTURE_2D, m_Id);
glPixelStorei(GL_UNPACK_ALIGNMENT, packBits);
glTexImage2D(GL_TEXTURE_2D,
0,
internalFormat,
m_Width,
m_Height,
0,
format,
GL_UNSIGNED_BYTE,
m_pImageData);
}
};
class Shader
{
private:
void LoadShader(GLuint id, const std::string& shaderCode)
{
const unsigned int NUM_SHADERS{ 1 };
const char* pCode{ shaderCode.c_str() };
GLint length{ static_cast<GLint>(shaderCode.length()) };
glShaderSource(id, NUM_SHADERS, &pCode, &length);
glCompileShader(id);
glAttachShader(m_ProgramId, id);
}
protected:
GLuint m_VertexShaderId{ GL_INVALID_VALUE };
GLuint m_FragmentShaderId{ GL_INVALID_VALUE };
GLint m_ProgramId{ GL_INVALID_VALUE };
std::string m_VertexShaderCode;
std::string m_FragmentShaderCode;
public:
Shader() = default;
virtual ~Shader() = default;
virtual void Link()
{
m_ProgramId = glCreateProgram();
m_VertexShaderId = glCreateShader(GL_VERTEX_SHADER);
LoadShader(m_VertexShaderId, m_VertexShaderCode);
m_FragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
LoadShader(m_FragmentShaderId, m_FragmentShaderCode);
glLinkProgram(m_ProgramId);
}
virtual void Setup(const Geometry& geometry)
{
glUseProgram(m_ProgramId);
}
};
class TextureShader
: public Shader
{
private:
const Texture& m_Texture;
GLint m_PositionAttributeHandle;
GLint m_TextureCoordinateAttributeHandle;
GLint m_SamplerHandle;
public:
TextureShader(const Texture& texture)
: m_Texture(texture)
{
m_VertexShaderCode =
"attribute vec4 a_vPosition; \n"
"attribute vec2 a_vTexCoord; \n"
"varying vec2 v_vTexCoord; \n"
" \n"
"void main() { \n"
" gl_Position = a_vPosition; \n"
" v_vTexCoord = a_vTexCoord; \n"
"} \n";
m_FragmentShaderCode =
"#version 150 \n"
" \n"
"varying vec2 v_vTexCoord; \n"
"uniform sampler2D s_2dTexture; \n"
" \n"
"void main() { \n"
" gl_FragColor = \n"
" texture2D(s_2dTexture, v_vTexCoord); \n"
"} \n";
}
~TextureShader() override = default;
void Link() override
{
Shader::Link();
m_PositionAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vPosition");
m_TextureCoordinateAttributeHandle = glGetAttribLocation(m_ProgramId, "a_vTexCoord");
m_SamplerHandle = glGetUniformLocation(m_ProgramId, "s_2dTexture");
}
void Setup(const Geometry& geometry) override
{
Shader::Setup(geometry);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_Texture.GetId());
glUniform1i(m_SamplerHandle, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glVertexAttribPointer(
m_PositionAttributeHandle,
geometry.GetNumVertexPositionElements(),
GL_FLOAT,
GL_FALSE,
geometry.GetVertexStride(),
geometry.GetVertices());
glEnableVertexAttribArray(m_PositionAttributeHandle);
glVertexAttribPointer(
m_TextureCoordinateAttributeHandle,
geometry.GetNumTexCoordElements(),
GL_FLOAT,
GL_FALSE,
geometry.GetVertexStride(),
geometry.GetTexCoords());
glEnableVertexAttribArray(m_TextureCoordinateAttributeHandle);
}
};
int main(void)
{
GLFWwindow* window;
// Initialize the library
if (!glfwInit())
{
return -1;
}
glfwWindowHint(GLFW_RED_BITS, 8);
glfwWindowHint(GLFW_GREEN_BITS, 8);
glfwWindowHint(GLFW_BLUE_BITS, 8);
glfwWindowHint(GLFW_DEPTH_BITS, 8);
glfwWindowHint(GLFW_DOUBLEBUFFER, true);
// Create a windowed mode window and its OpenGL context
window = glfwCreateWindow(480, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}
// Make the window's context current
glfwMakeContextCurrent(window);
GLenum glewError{ glewInit() };
if (glewError != GLEW_OK)
{
return -1;
}
TGAFile myTextureFile("earthmap.tga");
Texture myTexture(myTextureFile);
myTexture.Init();
TextureShader textureShader(myTexture);
textureShader.Link();
OBJFile objSphere("sphere.obj");
Geometry sphere(objSphere);
sphere.SetNumVertexPositionElements(3);
sphere.SetNumTexCoordElements(2);
sphere.SetVertexStride(sizeof(float) * 5);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
glEnable(GL_DEPTH_TEST);
// Loop until the user closes the window
while (!glfwWindowShouldClose(window))
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
textureShader.Setup(sphere);
glDrawElements(GL_TRIANGLES,
sphere.GetNumIndices(),
GL_UNSIGNED_SHORT,
sphere.GetIndices());
// Swap front and back buffers
glfwSwapBuffers(window);
// Poll for and process events
glfwPollEvents();
}
glfwTerminate();
return 0;
}
清单 14-14 显示了如何使用本菜谱和菜谱 14-3 中的类加载和呈现一个.obj
文件。在这个配方中,窗口的创建方式有一些不同。glfwWindowHint
函数指定了您希望应用程序的帧缓冲区拥有的一些参数。这里最重要的是深度缓冲。深度缓冲区在现代 GPU 上工作,在渲染过程中在每个片段位置存储来自多边形的 z 组件的标准化设备坐标。然后,您可以使用深度测试来允许或禁止在渲染期间向帧缓冲区写入新颜色。这在渲染球体时非常有用,可以确保球体后部渲染的像素不会覆盖球体前部片段的颜色。
面剔除也被启用,以确保你只能看到每个多边形的正面。多边形可以有两条边:正面和背面。OpenGL 根据顶点的缠绕顺序确定多边形是正面还是背面。默认情况下,OpenGL 确定顶点以逆时针顺序指定的多边形面向前面,顶点以顺时针顺序指定的多边形面向后面。当对象旋转时,这种情况会发生变化,因此 OpenGL 可以在多边形不面向相机时尽早丢弃多边形。如果您愿意,您可以使用glFrontFace
功能改变正面多边形的缠绕顺序。
从http://planetpixelemporium.com/earth.html
获得的earthmap.tga
纹理被加载以赋予球体行星地球的外观;球体本身是从名为sphere.obj
的文件中加载的。您可以通过调用glEnable
并传递GL_CULL_FACE
常量来启用正面剔除;通过调用glCullFace
指定要剔除的面。通过调用 glEnable 并传递GL_DEPTH_TEST
来启用深度测试;并且传递glClear
调用GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT
以确保颜色缓冲器和深度缓冲器在每个渲染帧的开始都被清除。
编译并运行随本书附带的网站数据一起提供的代码,产生一个程序来渲染地球,如图图 14-3 所示。
图 14-3 。清单 14-14 中的代码生成的渲染过的地球