C++-编程入门指南(全)

C++ 编程入门指南(全)

原文:annas-archive.org/md5/024671a6ef06ea57693023eca62b8eea

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C已经使用了 30 年,在这段时间里,许多新的语言出现并消失,但 C却一直存在。这本书背后的重要问题是:为什么?为什么要使用 C++?答案在你面前看到的十章中,但作为一个剧透,它是语言的灵活性和强大性以及丰富广泛的标准库。

C一直是一种强大的语言,可以直接访问内存,同时提供高级功能,如能够创建新类型(类)和根据需要重写运算符。然而,更现代的 C标准增加了模板的通用编程和函数对象和 lambda 表达式的函数式编程。您可以根据需要使用这些功能的多少;您可以使用抽象接口指针编写事件驱动的代码,也可以使用类似 C 的过程式代码。

在本书中,我们将介绍 C++ 2011 标准和语言提供的标准库的特性。本文解释了如何使用这些特性与简短的代码片段,并且每一章都有一个示例来说明概念。在本书结束时,您将了解语言的所有特性以及 C标准库的可能性。您将作为一个初学者开始本书,并在结束时具备使用 C的知识和能力。

本书内容

第一章,从 C++开始,解释了用于编写 C应用程序的文件,文件依赖关系以及 C项目管理的基础知识。

第二章,理解语言特性,涵盖了 C++语句和表达式、常量、变量、运算符以及如何控制应用程序中的执行流程。

第三章,探索 C++类型,描述了 C++内置类型、聚合类型、类型别名、初始化列表以及类型之间的转换。

第四章,使用内存、数组和指针,涵盖了 C应用程序中内存的分配和使用方式,如何使用内置数组,C引用的作用以及如何使用 C++指针来访问内存。

第五章,使用函数,解释了如何定义函数,如何通过引用和按值传递参数使用可变数量的参数,创建和使用函数指针,以及定义模板函数和重载运算符。

第六章,,描述了如何通过类定义新类型以及类中使用的各种特殊函数,如何将类实例化为对象以及如何销毁它们,以及如何通过指针访问对象以及如何编写模板类。

第七章,面向对象编程简介,解释了继承和组合,以及这如何影响使用指针和引用对象以及类成员的访问级别以及它们如何影响继承的成员。本章还通过虚方法解释了多态性,并通过抽象类解释了继承编程。

第八章,使用标准库容器,涵盖了所有 C++标准库容器类以及如何使用它们与迭代器和标准算法,以便操作容器中的数据。

第九章,使用字符串,描述了标准 C++字符串类的特性,将数值数据和字符串之间的转换,国际化字符串以及使用正则表达式搜索和操作字符串。

第十章,诊断和调试,解释了如何准备代码以提供诊断并使其能够进行调试,应用程序如何被终止,突然或优雅地,以及如何使用 C++异常。

本书所需的内容

本书涵盖了 C11 标准以及相关的 C标准库。在本书的绝大部分内容中,任何符合 C++11 标准的编译器都是合适的。这包括英特尔、IBM、Sun、苹果和微软等公司的编译器,以及开源 GCC 编译器。

本书使用 Visual C++ 2017 Community Edition,因为它是一个功能齐全的编译器和环境,并且可以免费下载。这是作者的个人选择,但不应限制喜欢使用其他编译器的读者。最后一章关于诊断和调试的一些部分描述了微软特定的功能,但这些部分已经清楚标记。

本书适合对象

本书适用于有经验的程序员,他们是 C++的新手。预期读者了解高级语言的用途以及模块化代码和控制执行流程等基本概念。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们可以通过使用include指令包含其他上下文。”

代码块设置如下:

    class point
    {
    public:
        int x, y;
    };

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

    class point
    {
    public:
        int x, y;
        point(int _x, int _y) : x(_x), y(_y) {}
    };

任何命令行输入或输出都以以下方式编写:

C:\> cl /EHsc test.cpp

新术语重要单词以粗体显示。屏幕上出现的单词,例如菜单或对话框中的单词,会在文本中显示为:“单击“下一步”按钮将您移至下一个屏幕。”

警告或重要说明会以这样的方式出现在框中。

提示和技巧会以这样的方式出现。

第一章:从 C++开始

为什么选择 C++?使用 C++的原因将会有很多,就像本书的读者一样多。

您可能选择 C是因为您需要支持一个 C项目。在其 30 年的生命周期中,已经有数百万行的 C代码编写,大多数流行的应用程序和操作系统都将主要由 C编写,或者将使用组件和库。几乎不可能找到一台不包含一些 C++编写的代码的计算机。

或者,您可能选择 C来编写新代码。这可能是因为您的代码将使用 C编写的库,而现有的库有成千上万种:开源、共享软件和商业软件。

或者您选择 C是因为您被 C所提供的强大和灵活性所吸引。现代高级语言被设计成让程序员轻松执行操作;而 C虽然也有这样的功能,但它也允许您尽可能接近机器,给您直接内存访问的(有时是危险的)能力。通过类和重载等语言特性,C是一种灵活的语言,允许您扩展语言的工作方式并编写可重用的代码。

无论您选择 C++的原因是什么,您都做出了正确的选择,而这本书是开始的正确地方。

本章中会有什么?

由于本书是一本实践性的书,它包含了您可以输入、编译和运行的代码。要编译代码,您需要一个 C编译器和链接器,在本书中意味着 Visual Studio 2017 Community Edition,它提供了 Visual C。选择这个编译器是因为它是免费下载的,它符合 C标准,并且具有非常广泛的工具范围,使编写代码更容易。Visual C提供了符合 C11 标准的语言特性,几乎所有 C14 和 C17 的语言特性。Visual C还提供了 C99 运行时库、C11 标准库和 C14 标准库。所有这些标准的提及意味着您在本书中学习到的代码将与所有其他标准的 C++编译器编译。

本章将从如何获取和安装 Visual Studio 2017 Community Edition 的细节开始。如果您已经有了 C++编译器,可以跳过本节。本书大部分内容对编译器和链接器工具都是中立的,但第十章《诊断和调试》将涵盖一些微软特定的功能,包括调试和诊断。Visual Studio 拥有功能齐全的代码编辑器,因此即使您不使用它来管理项目,您也会发现它在编辑代码时非常有用。

在我们描述了安装之后,您将学习 C++的基础知识:源文件和项目的结构,以及如何管理可能包含数千个文件的项目。

最后,本章将以一个逐步结构化的示例结束。在这里,您将学习如何编写使用标准 C++库和一个机制来管理项目中的文件的简单函数。

什么是 C++?

C++的前身是 C,由贝尔实验室的 Dennis Richie 设计,并于 1973 年首次发布。C 是一种广泛使用的语言,被用来编写 Unix 和 Windows 的早期版本。事实上,许多操作系统的库和软件开发库仍然是以 C 接口编写的。C 之所以强大,是因为它可以用来编写编译成紧凑形式的代码,它使用静态类型系统(因此编译器进行类型检查),语言的类型和结构允许直接访问计算机体系结构的内存。

然而,C 是基于函数的过程式语言,虽然它有记录类型(struct)来封装数据,但它没有对象行为来操作封装的状态。显然,需要的是 C 的强大功能,但又需要面向对象类的灵活性和可扩展性:一种具有类似 C 的语言。1983 年,Bjarne Stroustrup 发布了 C++。++ 来自于 C 的增量运算符 ++

严格地说,当后缀添加到变量时,++ 运算符表示增加变量的值,但返回增加之前的值。因此,C 语句 int c = 1; int d = c++; 将导致变量 d 的值为 1,变量 c 的值为 2。这并不完全表达了 C++ 是 C 的增量的概念。

安装 Visual C++

Microsoft 的 Visual Studio Community 2017 包含了 Visual C++ 编译器、C++ 标准库以及一系列标准工具,您可以使用这些工具来编写和维护 C++ 项目。本书不是关于如何编写 Windows 代码的,而是关于如何编写标准的 C++ 以及如何使用 C++ 标准库。本书中的所有示例都将在命令行上运行。选择 Visual Studio 是因为它是免费下载的(尽管您必须向 Microsoft 注册一个电子邮件地址),而且它符合标准。如果您已经安装了 C++ 编译器,那么您可以跳过本节。

设置中

在开始安装之前,您应该知道,作为安装 Visual Studio 的一部分,您应该具有 Microsoft 帐户,这是 Microsoft 社区计划的一部分。第一次运行 Visual Studio 时,您将有选项创建 Microsoft 帐户,如果您跳过此阶段,您将获得一个 30 天的评估期。在这一个月内,Visual Studio 将具有完整功能,但如果您想在此期限之后继续使用 Visual Studio,您将需要提供 Microsoft 帐户。Microsoft 帐户不会对您施加任何义务,当您使用 Visual C++ 登录后,您的代码仍将保留在您的计算机上,无需将其传递给 Microsoft。

当然,如果您在一个月内阅读本书,您将能够使用 Visual Studio 而无需使用 Microsoft 帐户登录;您可以将此视为完成本书的动力!

下载安装文件

要下载 Visual Studio Community 2017 安装程序,请访问 www.visualstudio.com/vs/ community/

单击“下载 Community 2017”按钮后,您的浏览器将下载一个名为 vs_community__1698485341.1480883588.exe 的 1 MB 文件。运行此应用程序后,它将允许您指定要安装的语言和库,然后下载并安装所有必要的组件。

安装 Visual Studio

Visual Studio 2017 将 Visual C++ 视为可选组件,因此您必须明确指示要通过自定义选项安装它。当您首次执行安装程序时,将会看到以下对话框:

单击“继续”按钮后,应用程序将设置安装程序,如下所示:

顶部有三个标签,分别标记为工作负载、单独组件和语言包。确保您已选择了“工作负载”标签(如截图所示),并选中了名为“使用 C++ 进行桌面开发”的复选框。

安装程序将检查您是否有足够的磁盘空间来安装所选的选项。Visual Studio 最大需要的空间为 8 GB,尽管对于 Visual C++,您将使用的空间要少得多。当您选择“使用 C++ 进行桌面开发”项目时,对话框的右侧将显示所选的选项和所需的磁盘空间,如下所示:

对于本书,保留安装程序选择的选项,然后单击右下角的“安装”按钮。安装程序将下载所有所需的代码,并将通过以下对话框框保持您更新进度:

安装完成后,Visual Studio Community 2017 项目将更改为具有两个按钮“修改”和“启动”,如下所示:

修改按钮允许您添加更多组件。单击“启动”以首次运行 Visual Studio。

与 Microsoft 注册

第一次运行 Visual Studio 时,它会要求您通过以下对话框登录到 Microsoft 服务:

您不必注册 Visual Studio,但如果选择不注册,Visual Studio 将只能使用 30 天。与 Microsoft 注册不会对您产生任何义务。如果您愿意注册,那么现在可以注册。单击“登录”按钮提供您的 Microsoft 凭据,或者如果您没有帐户,则单击“注册”以创建一个帐户。

当您单击“启动”按钮时,将打开一个新窗口,但安装程序窗口将保持打开状态。您可能会发现安装程序窗口隐藏了欢迎窗口,因此请检查 Windows 任务栏,看看是否有其他窗口打开。一旦 Visual Studio 启动,您可以关闭安装程序窗口。

现在您可以使用 Visual Studio 来编辑代码,并且在您的计算机上安装了 Visual C编译器和库,因此您可以在 Visual Studio 或命令行中编译 C代码。

检查 C++项目

C++项目可能包含数千个文件,管理这些文件可能是一项任务。构建项目时,应该编译文件,如果是的话,使用哪个工具?文件应该以什么顺序进行编译?这些编译器将产生什么输出?如何将编译后的文件组合以生成可执行文件?

编译器工具还将具有大量选项,如调试信息、优化类型、对不同语言功能和处理器功能的支持。在不同情况下将使用不同的编译器选项组合(例如,发布构建和调试构建)。如果您从命令行进行编译,您必须确保选择正确的选项并在所有编译的源代码中一致应用它们。

管理文件和编译器选项可能会变得非常复杂。这就是为什么在生产代码中,您应该使用一个 make 工具。Visual Studio 安装了两个:MSBuildnmake。在 Visual Studio 环境中构建 Visual C++项目时,将使用 MSBuild,并且编译规则将存储在一个 XML 文件中。您还可以在命令行上调用 MSBuild,传递 XML 项目文件。nmake 工具是微软版本的通用程序维护实用程序,适用于许多编译器。在本章中,您将学习如何编写一个简单的makefile以与 nmake 实用程序一起使用。

在进行项目管理基础知识之前,我们首先要检查您在 C++项目中通常会找到的文件,以及编译器对这些文件的处理。

编译器

C是一种高级语言,旨在为您和其他开发人员提供丰富的语言功能,并且易于阅读。计算机的处理器执行低级代码,编译器的目的是将 C翻译为处理器的机器代码。单个编译器可能能够针对多种类型的处理器进行编译,如果代码是标准 C++,则可以使用支持其他处理器的其他编译器进行编译。

然而,编译器做的远不止这些。正如第四章中所解释的,内存、数组和指针的使用,C++允许你将代码分割成函数,这些函数接受参数并返回一个值,因此编译器设置了用于传递这些数据的内存。此外,函数可以声明只在该函数内部使用的变量(第五章中将提供更多细节),并且只在函数执行时存在。编译器设置了这个内存,称为栈帧。你可以选择编译器选项来确定如何创建栈帧;例如,微软编译器选项/Gd/Gr/Gz确定了将函数参数推送到栈上的顺序,以及在调用结束时是调用函数还是被调用函数从栈上移除参数。当你编写将被共享的代码时,这些选项很重要(但是对于本书的目的,应该使用默认的栈构造)。这只是一个方面,但它应该让你明白编译器设置给了你很多的权力和灵活性。

编译器编译 C代码,如果在你的代码中遇到错误,它将发出编译器错误。这是对你的代码进行语法检查。重要的是要指出,你编写的代码可以从语法角度来看是完美的 C代码,但它仍然可能是无意义的。编译器的语法检查是对你的代码的重要检查,但你应该始终使用其他检查。例如,以下代码声明一个整数变量并为其赋值:

    int i = 1 / 0;

编译器将会发出错误C2124:除以零或取模。然而,以下代码将使用一个额外的变量执行相同的操作,逻辑上是相同的,但编译器不会发出错误:

    int j = 0; 
    int i = 1 / j;

当编译器发出错误时,它将停止编译。这意味着两件事。首先,你将得不到编译输出,因此错误不会出现在可执行文件中。其次,这意味着,如果源代码中还有其他错误,你只有在修复当前错误并重新编译后才能发现。如果你想进行语法检查并将编译留到以后,可以使用/Zs开关。

编译器还会生成警告消息。警告意味着代码将会编译,但代码中可能存在问题,这将影响可执行文件的运行。微软编译器定义了四个警告级别:级别 1 是最严重的(应该解决),级别 4 是信息性的。

警告通常用于指示正在编译的语言特性是可用的,但它需要开发人员未使用的特定编译器选项。在代码开发过程中,你通常会忽略警告,因为你可能正在测试语言特性。然而,当你接近生产代码时,你应该更加关注警告。默认情况下,微软编译器将显示级别 1 的警告,你可以使用/W选项加上一个数字来指示你希望看到的级别(例如,/W2表示你希望看到级别 2 的警告以及级别 1 的警告)。在生产代码中,你可以使用/Wx选项,它告诉编译器将警告视为错误,因此你必须修复问题才能编译代码。你还可以使用pragmas编译器(pragmas将在后面解释)和编译器选项来抑制特定的警告。

链接代码

编译器会产生一个输出。对于 C代码,这将是目标代码,但你可能会有其他编译器输出,比如编译后的资源文件。这些文件本身不能被执行;至少因为操作系统需要设置某些结构。一个 C项目总是两阶段的:将代码编译成一个或多个目标文件,然后将目标文件链接成一个可执行文件。这意味着你的 C++编译器将提供另一个工具,称为链接器。

链接器还有选项来确定它的工作方式并指定其输出和输入,它也会发出错误和警告。与编译器一样,微软的链接器有一个选项/WX,在发布版本中将警告视为错误。

源文件

在最基本的层面上,一个 C项目只包含一个文件:C源文件,通常扩展名为cppcxx

一个简单的例子

这里展示了最简单的 C++程序:

    #include <iostream> 

    // The entry point of the program 
    int main() 
    { 
        std::cout << "Hello, world!n"; 
    }

第一点要说明的是,以//开头的行是注释。编译器会忽略直到行末的所有文本。如果要有多行注释,每一行都必须以//开头。你也可以使用 C 注释。C 注释以/*开头,以*/结尾,两个符号之间的所有内容都是注释,包括换行符。

C 注释是注释掉代码的一种快速方式。

大括号{}表示一个代码块;在这种情况下,C++代码是为函数main而写的。我们知道这是一个函数,因为基本格式是:首先是返回值的类型,然后是函数的名称,后面跟着一对括号,用于声明传递给函数的参数(及其类型)。在这个例子中,函数名为main,括号是空的,表示该函数没有参数。函数名前的标识符(int)表示该函数将返回一个整数。

C++的约定是,一个名为main的函数是可执行文件的入口点,也就是说,当你从命令行调用可执行文件时,这将是你代码中将被调用的第一个函数。

这个简单的例子函数立即让你了解了 C的一个方面,即激怒其他语言的程序员:语言可能有规则,但规则并不总是被遵循。在这种情况下,main函数声明返回一个整数,但代码没有返回任何值。C的规则是,如果函数声明返回一个值,那么它必须返回一个值。然而,这个规则有一个例外:如果main函数不返回值,那么将假定返回值为0。C++包含许多这样的怪癖,但你很快就会学会它们并习惯它们。

main函数只有一行代码;这是一个以std开头并以分号(;)结尾的单个语句。C++对于空白符(空格、制表符和换行符)的使用是灵活的,这将在下一章中解释。然而,重要的是要注意,你必须小心处理文字字符串(如此处所用),并且每个语句都要用分号分隔。忘记必需的分号是编译器错误的常见来源。额外的分号只是一个空语句,所以对于新手来说,有太多分号可能对你的代码的影响要比太少分号要小。

单个语句将字符串Hello, world!(和一个换行符)打印到控制台。您知道这是一个字符串,因为它用双引号(″″)括起来。字符串使用运算符<< 放入流对象std::cout。名称中的std是一个命名空间,实际上是具有类似目的或来自单个供应商的代码集合。在这种情况下,std表示cout流对象是标准 C++库的一部分。双冒号::作用域解析运算符,表示您要访问在std命名空间中声明的cout对象。您可以定义自己的命名空间,在大型项目中应该定义自己的命名空间,因为这样可以使用可能已在其他命名空间中声明的名称,并且此语法允许您消除符号的歧义。

cout对象是ostream类的一个实例,并且在调用main函数之前已经为您创建。<<表示调用名为operator <<的函数,并传递字符串(这是一个char字符数组)。此函数将字符串中的每个字符打印到控制台,直到达到NUL字符。

这是 C灵活性的一个例子,一个称为运算符重载的特性。<<运算符通常与整数一起使用,并且也用于将整数中的位向左移动指定数量的位置;x << y将返回一个值,该值将x中的每个位向左移动y个位置,实际上返回一个乘以 2^y 的值。然而,在前面的代码中,x的位置是流对象std::cout,左移索引的位置是一个字符串。显然,这在 C<<运算符的定义中是没有意义的。当左侧是ostream对象时,C标准重新定义了<<运算符的含义。此外,此代码中的<<运算符将字符串打印到控制台,因此它在右侧需要一个字符串。C标准库定义了其他<<运算符,允许其他数据类型打印到控制台。它们都以相同的方式调用;编译器根据使用的参数类型确定编译哪个函数。

之前我们说过,std::cout对象已经作为ostream类的一个实例被创建,但没有说明这是如何发生的。这导致我们来到了尚未解释的简单源文件的最后一部分:以#include开头的第一行。这里的#有效地表示将向编译器发送某种消息。您可以发送各种类型的消息(其中一些是#define#ifdef#pragma,我们将在本书的其他地方返回)。在这种情况下,#include告诉编译器将指定文件的内容复制到此处的源文件中,这基本上意味着该文件的内容也将被编译。指定的文件称为头文件,在文件管理和通过库重用代码中很重要。

文件<iostream>(注意,没有扩展名)是标准库的一部分,可以在 C编译器提供的include 目录中找到。尖括号(<>)表示编译器应查找用于存储头文件的标准目录,但您可以使用双引号(″″)提供头文件的绝对位置(或相对于当前文件的位置)。C标准库使用不使用文件扩展名的约定。在命名自己的头文件时,应使用扩展名h(或hpp,很少使用hxx)。C 运行时库(也可用于 C++代码)还使用扩展名h来命名其头文件。

创建源文件

首先找到开始菜单中的 Visual Studio 2017 文件夹,然后点击 Developer Command Prompt for VS2017 的条目。这将启动一个 Windows 命令提示符,并设置环境变量以使用 Visual C++ 2017。然而,令人不满意的是,它也会将命令行留在 Program Files 文件夹下的 Visual Studio 文件夹中。如果你打算进行任何开发,你会想要离开这个文件夹,去一个创建和删除文件不会造成任何损害的地方。在你这样做之前,移动到 Visual C++文件夹并列出文件:

C:\Program Files\Microsoft Visual Studio\2017\Community>cd %VCToolsInstallDir%
C:\Program Files\Microsoft Visual Studio\2017\Community\VC\Tools\MSVC\14.0.10.2517>dir

由于安装程序将 C++文件放在一个包含当前编译器版本的文件夹中,因此最好使用环境变量VCToolsInstallDir,而不是指定特定版本,以便使用最新版本(在本例中为 14.0.10.2517)。

有几件事情需要注意。首先,文件夹binincludelib

文件夹 描述
bin 这个文件夹间接包含了 Visual C的可执行文件。bin文件夹将包含用于你正在使用的 CPU 类型的单独文件夹,因此你需要在其中导航以找到包含可执行文件的实际文件夹。两个主要的可执行文件是cl.exe,它是 C编译器,和link.exe,它是链接器。
include 这个文件夹包含了 C 运行库和 C++标准库的头文件。
lib 这个文件夹包含了 C 运行库和 C++标准库的静态链接库文件。同样,对于 CPU 类型会有单独的文件夹。

我们将在本章后面提到这些文件夹。

另一件要指出的事情是vcvarsall.bat文件,它位于VC\Auxillary\Build文件夹下。当你在开始菜单上点击 Developer Command Prompt for VS2017 时,这个批处理文件将被运行。如果你希望使用现有的命令提示符来编译 C++代码,你可以通过运行这个批处理文件来设置。这个批处理文件的三个最重要的操作是设置PATH环境变量以包含一个指向 bin 文件夹的路径,并设置INCLUDELIB环境变量分别指向 include 和 lib 文件夹。

现在导航到根目录并创建一个新文件夹Beginning_C++,然后进入该目录。接下来,创建一个名为Chapter_01的文件夹。现在你可以切换到 Visual Studio;如果它还没有运行,可以从开始菜单启动它。

在 Visual Studio 中,点击文件菜单,然后选择新建,再选择文件...菜单项,以打开新文件对话框,在左侧树视图中,点击 Visual C选项。在中间面板中,你会看到两个选项:C文件(.cpp)和头文件(.h),以及Open文件夹的 C++属性,如下截图所示:

前两种文件类型用于 C++项目,第三种类型创建一个 JSON 文件,以帮助 Visual Studio IntelliSence(在输入时提供帮助),并且在本书中不会使用。

点击第一个,然后点击打开按钮。这将创建一个名为 Source1.cpp 的新空文件,所以将其保存到章节项目文件夹中,命名为 simple.cpp,方法是点击文件菜单,然后选择另存为 Source1.cpp,然后导航到项目文件夹,在文件名框中更改名称为 simple.cpp,最后点击保存按钮。

现在你可以输入简单程序的代码,如下所示:

    #include <iostream> 

    int main() 
    { 
        std::cout << "Hello, world!n"; 
    }

当你输入完这段代码后,通过点击文件菜单然后选择菜单中的保存 simple.cpp 选项来保存文件。现在你已经准备好编译代码了。

编译代码

转到命令提示符,输入**cl /?**命令。由于PATH环境设置为包括bin文件夹的路径,您将看到编译器的帮助页面。您可以通过按回车键滚动这些页面,直到返回到命令提示符。这些选项中的大多数超出了本书的范围,但以下表格显示了我们将讨论的一些选项:

编译器开关 描述
/c 仅编译,不链接。
/D<symbol> 定义常量或宏
/EHsc 启用 C++异常处理,但指示不处理extern ″C″函数(通常是操作系统函数)的异常。
/Fe:<file> 提供要链接的可执行文件的名称。
/Fo:<file> 提供要编译的对象文件的名称。
/I <folder> 提供要用于搜索包含文件的文件夹的名称。
/link<linker options> 传递给链接器。这必须在源文件名和任何用于编译器的开关之后。
/Tp <file> 编译为 C++文件,即使它的文件扩展名不是.cpp.cxx
/U<symbol> 删除先前定义的宏或常量。
/Zi 启用调试信息。
/Zs 仅语法,不编译或链接。

请注意,某些选项需要开关和选项之间有空格,某些选项不能有空格,对于其他选项,空格是可选的。一般来说,如果您有一个包含空格的文件或文件夹的名称,您应该用双引号括起来。在使用开关之前,最好查阅帮助文件,了解它如何使用空格。

在命令行中,输入**cl simple.cpp**命令。您会发现编译器会发出警告**C4530****C4577**。原因是 C++标准库使用了异常,而您没有指定编译器应提供异常所需的支持代码。使用/EHsc开关很容易解决这些警告。在命令行中,输入cl /EHsc simple.cpp命令。如果您正确输入了代码,它应该可以编译:

C:\Beginning_C++\Chapter_01>cl /EHsc simple.cpp
Microsoft (R) C/C++ Optimizing Compiler Version 19.00.25017 for x86
Copyright (C) Microsoft Corporation.  All rights reserved

simple.cpp

Microsoft (R) Incremental Linker Version 14.10.25017.0
Copyright (C) Microsoft Corporation.  All rights reserved.
/out:simple.exe

simple.obj

默认情况下,编译器将文件编译为对象文件,然后将该文件作为命令行可执行文件传递给链接器,其名称与 C++文件相同,但扩展名为.exe/out:simple.exe一行是链接器生成的,/out是一个链接器选项。

列出文件夹的内容。您会发现三个文件:simple.cpp,源文件;simple.obj,编译器的输出对象文件;和simple.exe,链接器链接了对象文件和适当的运行时库后的输出。现在,您可以通过在命令行上输入simple来运行可执行文件:

C:\Beginning_C++\Chapter_01>simple
Hello, World!

在命令行和可执行文件之间传递参数

之前,您发现main函数返回一个值,默认情况下这个值是零。当应用程序完成时,您可以将错误代码返回到命令行;这样您可以在批处理文件和脚本中使用可执行文件,并使用该值来控制脚本内的流程。同样,当您运行一个可执行文件时,您可以从命令行传递参数,这将影响可执行文件的行为。

通过在命令行上输入**simple**命令来运行简单的应用程序。在 Windows 中,通过伪环境变量ERRORLEVEL获取错误代码,因此通过**ECHO**命令获取此值:

C:\Beginning_C++\Chapter_01>simple
Hello, World!

C:\Beginning_C++\Chapter_01>ECHO %ERRORLEVEL%
0

为了显示应用程序返回的值,将main函数更改为返回非零值(在本例中为 99,如下所示):

    int main() 
    { 
        std::cout << "Hello, world!n"; 
 return 99; 
    }

编译此代码并运行它,然后按照之前显示的方式打印出错误代码。您会发现错误代码现在是 99。

这是一种非常基本的通信机制:它只允许你传递整数值,调用你的代码的脚本必须知道每个值的含义。你更有可能向应用程序传递参数,并且这些参数将通过main函数的参数传递到你的代码中。用以下内容替换main函数:

        int main(int argc, char *argv[]) 
        { 
            std::cout << "there are " << argc << " parameters" <<  
            std::endl; 
            for (int i = 0; i < argc; ++i) 
            { 
                std::cout << argv[i] << std::endl; 
            } 
        }

当你编写main函数从命令行接受参数时,约定是它有这两个参数。

第一个参数通常被称为argc。它是一个整数,表示传递给应用程序的参数数量。这个参数非常重要。原因是你将要通过数组访问内存,这个参数给出了你的访问限制。如果你超出这个限制访问内存,你会遇到问题:最好的情况是访问未初始化的内存,但最坏的情况是可能导致访问违规。

每当访问内存时,重要的是要了解你正在访问的内存量,并保持在其限制内。

第二个参数通常被称为argv,是一个指向内存中 C 字符串的指针数组。你将在第四章 使用内存、数组和指针中学到更多关于数组和指针的知识,以及在第九章 使用字符串中学到更多关于字符串的知识,所以我们在这里不会进行详细讨论。方括号([])表示参数是一个数组,数组的每个成员的类型由char *给出。*表示每个项目是指向内存的指针。通常,这会被解释为指向给定类型的单个项目的指针,但字符串是不同的:char *表示在指针指向的内存中将会有零个或多个字符,后跟NUL字符()。字符串的长度是直到NUL字符的字符数。

这里显示的第三行向控制台打印了传递给应用程序的字符串数量。在这个例子中,我们使用流std::endl而不是使用换行转义字符(n)来添加换行。有几个操纵符可以使用,这将在第六章中讨论。std::endl操纵符会将换行字符放入输出流,然后刷新流。这行显示了 C++允许你将<<放操作符链接到流中。这行还向你展示了<<放操作符被重载,也就是说,对于不同的参数类型有不同版本的操作符(在这种情况下有三个:一个接受整数的,用于argv,一个接受字符串参数的,另一个接受操纵符作为参数),但调用这些操作符的语法是完全相同的。

最后,有一个代码块来打印argv数组中的每个字符串,如下所示:

    for (int i = 0; i < argc; ++i) 
    { 
        std::cout << argv[i] << std::endl; 
    }

for语句意味着代码块将被调用,直到变量i小于argc的值,并且在每次成功迭代循环后,变量i会被递增(使用前缀递增操作符++)。通过方括号语法([])访问数组中的项目。传递的值是数组的索引

注意,变量i的起始值为0,所以访问的第一个项目是argv[0],并且由于for循环在变量i的值为argc时结束,这意味着数组中访问的最后一个项目是argv[argc-1]。这是数组的典型用法:第一个索引是零,如果数组中有n个项目,最后一个项目的索引是n-1

像之前一样编译和运行这段代码,不带参数:

C:\Beginning_C++\Chapter_01>simple
there are 1 parameters
simple

请注意,尽管你没有给出参数,程序认为有一个参数:程序可执行文件的名称。实际上,这不仅仅是名称,它是用于调用可执行文件的命令。在这种情况下,你输入了**simple**命令(没有扩展名),并在控制台上打印了文件simple的值作为参数。再试一次,但这次使用完整名称simple.exe调用程序。现在你会发现第一个参数是simple.exe

尝试使用一些实际参数调用代码。在命令行中输入**simple test parameters**命令:

C:\Beginning_C++\Chapter_01>simple test parameters
there are 3 parameters
simple
test parameters

这次程序说有三个参数,并且它已经使用空格字符进行了分隔。如果你想在单个参数中使用空格,你应该将整个字符串放在双引号中:

C:\Beginning_C++\Chapter_01>simple ″test parameters″
there are 2 parameters
simple
test parameters

请记住,argv是一个字符串指针数组,所以如果你想从命令行传递一个数字类型,并且想在程序中使用它作为一个数字,你将不得不从通过argv访问的字符串表示中进行转换。

预处理器和符号

C++编译器在编译源文件时会经历几个步骤。顾名思义,编译器预处理器处于这个过程的开始。预处理器定位头文件并将它们插入到源文件中。它还替换宏和定义的常量。

定义常量

定义常量的两种主要方法是通过预处理器:通过编译器开关和代码。要查看这是如何工作的,让我们将main函数更改为打印常量的值;两个重要的行已经突出显示:

    #include <iostream>  
 #define NUMBER 4 

    int main() 
    { 
 std::cout << NUMBER << std::endl; 
    }

#define开头的行是对预处理器的指令,它说,无论在文本中哪里有确切的符号NUMBER,它都应该被替换为 4。这是一个文本搜索和替换,但它只会替换整个符号(因此如果文件中有一个叫做NUMBER99的符号,NUMBER部分将不会被替换)。预处理器完成工作后,编译器将看到以下内容:

    int main() 
    { 
 std::cout << 4 << std::endl; 
    }

编译原始代码并运行它,并确认程序只是将 4 打印到控制台。

预处理器的文本搜索和替换方面可能会导致一些奇怪的结果,例如,将你的main函数更改为声明一个名为NUMBER的变量:

    int main() 
    { 
 int NUMBER = 99; 
        std::cout << NUMBER << std::endl; 
    }

现在编译代码。你将会收到来自编译器的错误:

C:\Beginning_C++\Chapter_01>cl /EHhc simple.cpp
Microsoft (R) C/C++ Optimizing Compiler Version 19.00.25017 for x86
Copyright (C) Microsoft Corporation.  All rights reserved.

simple.cpp
simple.cpp(7): error C2143: syntax error: missing ';' before 'constant'
simple.cpp(7): error C2106: '=': left operand must be l-value

这表明第 7 行有一个错误,这是声明变量的新行。然而,由于预处理器进行的搜索和替换,编译器看到的是以下内容:

    int 4 = 99;

这不是正确的 C++!

在你输入的代码中,很明显是什么导致了问题,因为你在同一个文件中为该符号使用了#define指令。实际上,你将包括几个头文件,这些头文件本身可能包括文件,因此错误的#define指令可能在许多文件中的一个中。同样,你的常量符号可能与在#define指令之后包含的头文件中的变量具有相同的名称,并且可能被预处理器替换。

使用#define作为定义全局常量的方法通常不是一个好主意,在 C中有更好的方法,正如你将在第三章中看到的,*探索 C类型*。

如果你认为问题是由预处理器替换符号引起的,你可以通过查看预处理器完成工作后传递给编译器的源文件来进行调查。为此,使用/EP开关进行编译。这将抑制实际编译并将预处理器的输出发送到stdout(命令行)。请注意,这可能会产生大量文本,因此通常最好将此输出定向到文件,并使用 Visual Studio 编辑器检查该文件。

提供给预处理器使用的值的另一种方法是通过编译器开关传递它们。编辑代码并删除以#define开头的行。像往常一样编译此代码(**cl /EHsc simple.cpp**),运行它,并确认在控制台上打印的数字是 99,即分配给变量的值。现在再次使用以下行编译代码:

cl /EHsc simple.cpp /DNUMBER=4

请注意,/D 开关和符号名称之间没有空格。这告诉预处理器将每个NUMBER符号替换为文本4,这将导致与上述相同的错误,表明预处理器试图用提供的值替换符号。

诸如 Visual C和 nmake 项目之类的工具将通过 C编译器定义符号的机制。/D 开关用于定义一个符号,如果要定义其他符号,它们将有自己的/D 开关。

您现在想知道为什么 C++有这样一个看似只会导致混乱错误的奇怪功能。一旦您了解了预处理器的工作原理,定义符号就可以变得非常强大。

使用宏

预处理器符号的一个有用特性是。宏具有参数,预处理器将确保搜索和替换将使用宏中的符号替换为宏的参数。

编辑main函数以如下所示:

    #include <iostream> 

    #define MESSAGE(c, v)  
    for(int i = 1; i < c; ++i) std::cout << v[i] << std::endl; 

    int main(int argc, char *argv[]) 
    { 
        MESSAGE(argc, argv); 
        std::cout << "invoked with " << argv[0] << std::endl; 
    }

main函数调用一个名为MESSAGE的宏,并将命令行参数传递给它。然后该函数将第一个命令行参数(调用命令)打印到控制台上。MESSAGE不是一个函数,它是一个宏,这意味着预处理器将用之前定义的文本替换每个带有两个参数的MESSAGE的出现,将c参数替换为宏的第一个参数,将v替换为宏的第二个参数。预处理器处理完文件后,main将如下所示:

    int main(int argc, char *argv[]) 
    { 
        for(int i = 1; i < argc; ++i)  
            std::cout << argv[i] << std::endl; 
        std::cout << "invoked with " << argv[0] << std::endl; 
    }

请注意,在宏定义中,反斜杠(\)用作换行字符,因此您可以有多行宏。使用一个或多个参数编译和运行此代码,并确认MESSAGE打印出命令行参数。

使用符号

您可以定义一个没有值的符号,并且可以告诉预处理器测试符号是否已定义。最明显的情况是为调试构建和发布构建编译不同的代码。

编辑代码以添加此处突出显示的行:

 #ifdef DEBUG 
    #define MESSAGE(c, v)  
    for(int i = 1; i < c; ++i) std::cout << v[i] << std::endl; 
 #else #define MESSAGE #endif

第一行告诉预处理器查找DEBUG符号。如果定义了此符号(无论其值如何),则将使用MESSAGE宏的第一个定义。如果未定义该符号(发布构建),则MESSAGE符号被定义,但不执行任何操作:基本上,带有两个参数的MESSAGE的出现将从代码中删除。

编译此代码并使用一个或多个参数运行程序。例如:

C:\Beginning_C++\Chapter_01>simple test parameters
invoked with simple

这表明代码已经编译而没有定义DEBUG,因此MESSAGE被定义为不执行任何操作。现在再次编译此代码,但这次使用/DDEBUG 开关来定义DEBUG符号。再次运行程序,您将看到命令行参数被打印到控制台上:

C:\Beginning_C++\Chapter_01>simple test parameters
test parameters 
invoked with simple

此代码使用了一个宏,但您可以在 C++代码的任何地方使用符号进行条件编译。以这种方式使用的符号允许您编写灵活的代码,并通过编译器命令行上定义的符号选择要编译的代码。此外,编译器本身将定义一些符号,例如,__DATE__将具有当前日期,__TIME__将具有当前时间,__FILE__将具有当前文件名。

Microsoft 和其他编译器生产商定义了一长串可以访问的符号,建议您在手册中查找这些符号。您可能会发现一些有用的符号如下:__cplusplus将为 C源文件定义(但不会为 C 文件定义),因此您可以识别需要 C编译器的代码;_DEBUG用于调试构建(请注意前面的下划线),_MSC_VER具有 Visual C++编译器的当前版本,因此您可以在各个版本的编译器中使用相同的源代码。

使用编译器指令

与符号和条件编译相关的是编译器指令#pragma once。编译器特定的指令是编译器特定的指令,不同的编译器将支持不同的指令。Visual C定义了#pragma once来解决当您有多个头文件每个包含类似的头文件时出现的问题。问题是可能导致相同的项目被定义多次,编译器将将其标记为错误。有两种方法可以解决这个问题,您下一个包含的<iostream>头文件将使用这两种技术。您可以在 Visual Cinclude文件夹中找到此文件。在文件顶部,您将找到以下内容:

    // ostream standard header 
    #pragma once 
    #ifndef _IOSTREAM_ 
    #define _IOSTREAM_

在底部,您将找到以下行:

    #endif /* _IOSTREAM_ */

首先是条件编译:第一次包含此头文件时,符号_IOSTREAM_将未定义,因此该符号被定义,然后其余文件将被包含直到#endif行。

这说明了在使用条件编译时的良好实践。对于每个#ifndef,必须有一个#endif,并且它们之间可能会有数百行。当您使用#ifdef#ifundef时,最好提供一个注释,说明它所指的符号以及相应的#else#endif

如果文件再次被包含,则符号_IOSTREAM_将被定义,因此在#ifndef#endif之间的代码将被忽略。但是,重要的是要指出,即使定义了该符号,头文件仍将被加载和处理,因为关于如何处理的指令包含在文件中。

#pragma once执行与条件编译相同的操作,但它解决了使用可能重复的符号的问题。如果将这一行添加到头文件的顶部,您就是在指示预处理器加载和处理此文件一次。预处理器维护了它已处理的文件列表,如果随后的头文件尝试加载已经处理过的文件,那么该文件将不会被加载也不会被处理。这减少了项目预处理所需的时间。

在关闭<iostream>文件之前,查看文件中的行数。对于<iostream>版本 v6.50:0009,有 55 行。这是一个小文件,但它包括<istream>(1,157 行),其中包括<ostream>(1,036 行),其中包括<ios>(374 行),其中包括<xlocnum>(1,630 行),依此类推。预处理的结果可能意味着即使对于只有一行代码的程序,也会包含成千上万行的源文件!

依赖关系

C项目将生成可执行文件或库,这将由链接器从目标文件构建。可执行文件或库依赖于这些目标文件。目标文件将从 C源文件(可能还有一个或多个头文件)编译而成。目标文件依赖于这些 C++源文件和头文件。理解依赖关系很重要,因为它可以帮助您理解项目中编译文件的顺序,并且可以通过仅编译已更改的文件来加快项目构建速度。

当您在源文件中包含一个文件时,该头文件中的代码将对您的代码可访问。您的包含文件可能包含整个函数或类的定义(这将在后面的章节中介绍),但这将导致前面提到的问题:函数或类的多重定义。相反,您可以声明一个类或函数原型,它指示调用代码将如何调用函数,而不实际定义它。显然,代码必须在其他地方定义,这可以是源文件或库,但编译器会很高兴,因为它只看到一个定义。

库是已经定义好的代码;它已经完全调试和测试过,因此用户不应该需要访问源代码。C++标准库主要通过头文件共享,这有助于您调试代码,但您必须抵制任何编辑这些文件的诱惑。其他库将以编译后的库的形式提供。

基本上有两种类型的编译库:静态库和动态链接库。如果您使用静态库,那么编译器将从静态库中复制您使用的编译代码,并将其放入可执行文件中。如果您使用动态链接(或共享)库,那么链接器将在运行时添加信息(可能是在加载可执行文件时,或者甚至延迟到调用函数时)来将共享库加载到内存中并访问函数。

Windows 使用扩展名lib表示静态库,dll表示动态链接库。GNU gcc使用扩展名a表示静态库,so表示共享库。

如果您在静态或动态链接库中使用库代码,编译器将需要知道您是否正确地调用函数-以确保您的代码调用具有正确数量的参数和正确类型的函数。这就是函数原型的目的:它为编译器提供了有关调用函数的信息,而不提供函数的实际主体,即函数定义。

本书不会详细介绍如何编写库,因为这取决于编译器;也不会详细介绍调用库代码的细节,因为不同的操作系统有不同的共享代码方式。一般来说,C标准库将通过标准头文件包含在您的代码中。C 运行时库(为 C标准库提供一些代码)将被静态链接,但如果编译器提供动态链接版本,您将有一个编译器选项来使用它。

预编译头文件

当您将一个文件包含到您的源文件中时,预处理器将包含该文件的内容(在考虑任何条件编译指令后),以及递归地包含该文件包含的任何文件。正如前面所示,这可能导致成千上万行的代码。在开发代码时,您经常会编译项目以便测试代码。每次编译代码时,头文件中定义的代码也将被编译,即使库头文件中的代码没有改变。对于大型项目,这可能会导致编译花费很长时间。

为了解决这个问题,编译器通常提供一个选项来预编译那些不会改变的头文件。创建和使用预编译头文件是与编译器相关的。例如,使用 GNU C编译器 gcc,您可以将头文件编译为 C源文件(使用/x开关),编译器将创建一个扩展名为gch的文件。当 gcc 编译使用该头文件的源文件时,它将搜索gch文件,如果找到预编译头文件,它将使用该文件;否则,它将使用头文件。

在 Visual C中,这个过程稍微复杂一些,因为你必须明确告诉编译器在编译源文件时查找预编译头文件。在 Visual C项目中的约定是创建一个名为stdafx.cpp的源文件,其中包含一行代码,包括文件stdafx.h。你将所有稳定的头文件包含在stdafx.h中。接下来,通过使用/Yc编译器选项编译stdafx.cpp来创建一个预编译头文件,指定stdafx.h包含了要编译的稳定头文件。这将创建一个pch文件(通常,Visual C++会根据你的项目命名),其中包含了到包含stdafx.h头文件的代码编译的内容。你的其他源文件必须将stdafx.h头文件包含为第一个头文件,但它们也可以包含其他文件。在编译源文件时,你使用/Yu开关来指定稳定的头文件(stdafx.h),编译器将使用预编译头文件pch而不是头文件。

当你检查大型项目时,你经常会发现使用了预编译头文件;正如你所看到的,它改变了项目的文件结构。本章后面的示例将展示如何创建和使用预编译头文件。

项目结构

将代码组织成模块对于有效地进行维护非常重要。第七章,面向对象编程简介,解释了面向对象编程,这是一种组织和重用代码的方式。然而,即使你在编写类似 C 的过程式代码(即,你的代码涉及线性调用函数),你也会受益于将其组织成模块。例如,你可能有处理字符串的函数和访问文件的其他函数,因此你可能决定将字符串函数的定义放在一个源文件string.cpp中,将文件函数的定义放在另一个文件file.cpp中。为了让项目中的其他模块可以使用这些文件,你必须在一个头文件中声明这些函数的原型,并在使用这些函数的模块中包含该头文件。

语言中没有绝对的规则来规定头文件和包含函数定义的源文件之间的关系。你可以为string.cpp中的函数创建一个名为string.h的头文件;为file.cpp中的函数创建一个名为file.h的头文件。或者你可以只创建一个名为utilities.h的文件,其中包含了这两个文件中所有函数的声明。唯一的规则是,在编译时,编译器必须能够访问当前源文件中函数的声明,无论是通过头文件还是函数定义本身。

编译器不会在源文件中向前查找,因此如果函数A在同一源文件中调用另一个函数B,那么函数B必须在函数A调用它之前已经被定义,或者必须有一个原型声明。这导致了一个典型的约定,即为每个包含源文件中函数原型的源文件创建一个关联的头文件,并且源文件包含这个头文件。当你编写类时,这个约定变得更加重要。

管理依赖关系

当使用构建工具构建项目时,会执行检查以查看构建的输出是否存在,如果不存在,则执行构建所需的适当操作。常见的术语是构建步骤的输出称为目标,构建步骤的输入(例如,源文件)是该目标的依赖项。每个目标的依赖项是用于生成它们的文件。这些依赖项本身可能是构建操作的目标,并且具有它们自己的依赖项。

例如,下面的图表显示了一个项目中的依赖关系:

在这个项目中,有三个源文件(main.cppfile1.cppfile2.cpp)。每个文件都包含相同的头文件utils.h,这是预编译的(因此有第四个源文件utils.cpp,只包含utils.h)。所有源文件都依赖于utils.pch,而utils.pch又依赖于utils.h。源文件main.cppmain函数,并调用其他两个源文件(file1.cppfile2.cpp)中的函数,并通过相关的头文件file1.hfile2.h访问这些函数。

在第一次编译时,构建工具将看到可执行文件依赖于四个对象文件,因此它将寻找构建每个对象文件的规则。对于三个 C++源文件,这意味着编译cpp文件,但由于utils.obj用于支持预编译头,构建规则将与其他文件不同。当构建工具制作了这些对象文件后,它将把它们与任何库代码一起链接在一起(这里没有显示)。

随后,如果你改变file2.cpp并构建项目,构建工具将看到只有file2.cpp已经改变,而只有file2.obj依赖于file2.cpp,那么 make 工具需要做的就是编译file2.cpp,然后将新的file2.obj与现有的对象文件链接在一起创建可执行文件。如果你改变头文件file2.h,构建工具将看到两个文件依赖于这个头文件,file2.cppmain.cpp,因此构建工具将编译这两个源文件,并将新的两个对象文件file2.objmain.obj与现有的对象文件链接在一起形成可执行文件。然而,如果预编译头源文件util.h改变了,这意味着所有源文件都必须被编译。

对于一个小项目,依赖关系很容易管理,正如你所看到的,对于一个单个源文件项目,你甚至不必担心调用链接器,因为编译器会自动完成这一步。随着 C项目变得更大,管理依赖关系变得更加复杂,这就是开发环境如 Visual C变得至关重要的地方。

Makefiles

如果你正在支持一个 C++项目,你可能会遇到一个 makefile。这是一个文本文件,包含项目中目标、依赖关系和构建目标的规则。makefile 通过 make 工具调用,Windows 上是 nmake,Unix 类平台上是 make。

makefile 是一系列规则,看起来如下:

 targets : dependents 
        commands 

目标是一个或多个文件,依赖于依赖项(可能是多个文件),因此如果一个或多个依赖项比一个或多个目标更新(因此自上次构建目标以来已更改),则需要重新构建目标,这是通过运行命令来完成的。可能有多个命令,每个命令都在一个单独的行上,以制表符字符为前缀。一个目标可能没有依赖项,这种情况下命令总是会被调用。

例如,使用上面的例子,可执行文件test.exe的规则将如下:

    test.exe : main.obj file1.obj file2.obj utils.obj 
        link /out:test.exe main.obj file1.obj file2.obj utils.obj

由于main.obj对象文件依赖于源文件main.cpp,头文件File1.hFile2.h,以及预编译头utils.pch,因此该文件的规则如下:

    main.obj : main.cpp file1.h file2.h utils.pch 
        cl /c /Ehsc main.cpp /Yuutils.h

编译器使用/c开关调用,表示代码被编译为对象文件,但编译器不应调用链接器。编译器被告知使用预编译头文件utils.pch通过头文件utils.h使用/Yu开关。其他两个源文件的规则将类似。

创建预编译头文件的规则如下:

    utils.pch : utils.cpp utils.h 
        cl /c /EHsc utils.cpp /Ycutils.h

/Yc开关告诉编译器使用头文件utils.h创建预编译头。

Makefile 通常比这复杂得多。它们将包含宏,用于组合目标、依赖项或命令开关。它们将包含目标类型的通用规则,而不是这里显示的具体规则,并且它们将包含条件测试。如果您需要支持或编写 makefile,则应查阅工具的手册中的所有选项。

编写一个简单的项目

该项目将演示您在本章中学到的 C++和项目的特性。该项目将使用多个源文件,以便您可以看到依赖关系的影响以及构建工具如何管理对源文件的更改。该项目很简单:它将要求您输入您的名字,然后将您的名字、时间和日期打印到命令行。

项目结构

该项目使用三个函数:main函数调用两个函数print_nameprint_time。这些函数在三个单独的源文件中,由于main函数将调用其他两个源文件中的函数,这意味着main源文件将需要这些函数的原型。在这个例子中,这意味着每个文件都需要一个头文件。该项目还将使用预编译头文件,这意味着一个源文件和一个头文件。总共,这意味着将使用三个头文件和四个源文件。

创建预编译头文件

该代码将使用 C标准库通过流进行输入和输出,因此将使用<iostream>头文件。该代码将使用 Cstring类型来处理输入,因此将使用<string>头文件。最后,它访问 C 运行时的时间和日期函数,因此代码将使用<ctime>头文件。这些都是标准头文件,在开发项目时不会更改,因此它们是预编译的良好候选。

在 Visual Studio 中创建一个 C++头文件,并添加以下行:

    #include <iostream> 
    #include <string> 
    #include <ctime>

将文件保存为utils.h

现在创建一个 C++源文件,并添加一行以包含您刚刚创建的头文件:

    #include ″utils.h″

将其保存为utils.cpp。您需要为项目创建一个 makefile,因此在新文件对话框中,选择文本文件作为文件类型。添加以下用于构建预编译头文件的规则:

    utils.pch utils.obj :: utils.cpp utils.h 
        cl /EHsc /c utils.cpp /Ycutils.h

将此文件保存为makefile.并附加句点。由于您将此文件保存为文本文件,Visual Studio 通常会自动将其扩展名更改为txt,但由于我们不需要扩展名,因此您需要添加句点以指示没有扩展名。第一行表示两个文件utils.pchutils.obj依赖于指定的源文件和头文件。第二行(以制表符为前缀)告诉编译器编译 C++文件,而不是调用链接器,并告诉编译器将预编译代码保存到utils.h中。该命令将创建utils.pchutils.obj,这两个指定的目标。

当 make 实用程序看到有两个目标时,默认操作(当目标和依赖项之间使用单冒号时)是为每个目标调用一次命令(您可以使用宏来确定正在构建哪个目标)。这意味着同一个编译器命令将被调用两次。我们不希望出现这种行为,因为两个目标是通过一次调用命令创建的。双冒号::是一个解决方法:它告诉 nmake 不要使用为每个目标调用命令的行为。结果是,当 make 实用程序调用一次命令创建utils.pch后,它会尝试创建utils.obj,但看到它已经创建,因此意识到不需要再次调用命令。

现在测试一下。在包含您的项目的文件夹中,输入nmake命令。

如果您没有给出 makefile 的名称,程序维护工具将自动使用名为makefile的文件(如果要使用其他名称的 makefile,请使用/f开关提供名称):

C:\Beginning_C++\Chapter_01\Code>nmake
Microsoft (R) Program Maintenance Utility Version 14.00.24210.0
Copyright (C) Microsoft Corporation.  All rights reserved.

cl /EHsc /c utils.cpp /Ycutils.h
Microsoft (R) C/C++ Optimizing Compiler Version 19.00.24210 for x86
Copyright (C) Microsoft Corporation.  All rights reserved.

utils.cpp

进行目录列表以确认utils.pchutils.obj已经生成。

创建主文件

现在创建一个 C++源文件,并添加以下代码:

    #include "utils.h" 
    #include "name.h" 
    #include "time.h" 

    void main() 
    { 
        print_name(); 
        print_time(); 
    }

将此文件保存为main.cpp

第一个包含文件是标准库头文件的预编译头文件。另外两个文件提供了两个在main函数中调用的函数原型声明。

现在您需要为 makefile 添加一个main文件的规则。在文件顶部添加以下突出显示的行:

 main.obj : main.cpp name.h time.h utils.pch cl /EHsc /c main.cpp /Yuutils.h 

    utils.pch utils.obj :: utils.cpp utils.h 
        cl /EHsc /c utils.cpp /Ycutils.h

这一新行表示main.obj目标依赖于两个头文件:一个源文件和预编译头文件utils.pch。此时,main.cpp文件将无法编译,因为头文件尚不存在。为了测试 makefile,创建两个 C++头文件;在第一个头文件中,添加函数原型:

    void print_name();

将此文件保存为name.h。在第二个头文件中,添加函数原型:

    void print_time();

将此文件保存为time.h

现在可以运行 make 工具,它将只编译main.cpp文件。测试一下:通过在命令行上键入del main.obj utils.obj utils.pch来删除所有目标文件,然后再次运行 make 工具。这一次,您会看到 make 工具首先编译utils.cpp,然后编译main.cpp。之所以按照这个顺序是因为第一个目标是main.obj,但由于这取决于utils.pch,make 工具会转移到下一个规则,并使用它来创建预编译头文件,然后返回到创建main.obj的规则。

请注意,您尚未定义print_nameprint_time,但编译器并未抱怨。原因是编译器只创建对象文件,解析函数链接的责任属于链接器。头文件中的函数原型满足编译器,函数将在另一个对象文件中定义。

使用输入和输出流

到目前为止,我们已经看到如何通过cout对象将数据输出到控制台。标准库还提供了一个cin流对象,允许您从命令行输入值。

创建一个 C++源文件,并添加以下代码:

    #include "utils.h" 
    #include "name.h" 

    void print_name() 
    { 
        std::cout << "Your first name? "; 
        std::string name; 
        std::cin >> name; 
        std::cout << name; 
    }

将此文件保存为name.cpp

第一个包含文件是预编译头文件,它将包括两个标准库头文件<iostream><string>,因此您可以使用这些文件中声明的类型。函数的第一行在控制台上打印字符串“Your first name?”。请注意,查询后有一个空格,因此光标将保持在同一行上,准备输入。

下一行声明了一个 C++ string 对象变量。字符串是零个或多个字符,每个字符将占用内存。string 类负责分配和释放字符串将使用的内存。这个类将在第八章中更详细地描述,使用标准库容器cin重载了>>运算符,从控制台获取输入。当您按下 Enter 键时,>>运算符将返回您键入到name变量中的字符(将空格字符视为分隔符)。然后函数将在不换行的情况下将name变量的内容打印到控制台上。

现在为此源文件添加一个规则到 makefile;在文件顶部添加以下行:

    name.obj : name.cpp name.h utils.pch 
        cl /EHsc /c name.cpp /Yuutils.h

保存此文件并运行 make 工具,确认它将创建name.obj目标。

使用时间函数

最终的源文件将获取时间并将其打印在控制台上。创建一个 C++源文件,并添加以下行:

    #include "utils.h" 
    #include "time.h" 

    void print_time() 
    { 
        std::time_t now = std::time(nullptr); 
        std::cout << ", the time and date are " 
                  << std::ctime(&now) << std::endl; 
    }

两个函数std::timestd::gmtime是 C 函数,std::time_t是 C 类型;所有这些都可以通过 C标准库获得。std::time函数获取自 1970 年 1 月 1 日午夜以来的秒数作为时间。该函数返回std::time_t类型的值,这是一个 64 位整数。如果您传递一个指向变量存储位置的指针,该函数可以选择将此值复制到另一个变量中。在这个例子中,我们不需要这个功能,所以我们将 Cnullptr传递给函数,表示不应执行复制。

接下来,我们需要将秒数转换为您可以理解的时间和日期格式的字符串。这就是std::ctime函数的目的,它以指向保存秒数的变量的指针作为参数。now变量包含秒数,&运算符用于获取该变量在内存中的地址。内存和指针在第四章,内存、数组和指针的使用中有更详细的介绍。该函数返回一个字符串,但您没有为该字符串分配任何内存,也不应尝试释放该字符串使用的内存。std::ctime函数创建一个静态分配的内存缓冲区,该缓冲区将被当前执行线程上运行的所有代码使用。每次在同一执行线程上调用std::ctime函数时,使用的内存位置将是相同的,尽管内存的内容可能会改变。

这个函数说明了查看手册以查看谁负责分配和释放内存是多么重要。第四章,内存、数组和指针的使用,更详细地介绍了内存分配。

std::ctime返回的字符串使用多次调用<<运算符打印到控制台以格式化输出。

现在在 makefile 中添加一个构建规则。在文件顶部添加以下内容:

    time.obj : time.cpp time.h utils.pch 
        cl /EHsc /c time.cpp /Yuutils.h

保存此文件并运行 make 工具,并确认它构建了time.obj目标。

构建可执行文件

现在您已经拥有项目所需的所有对象文件,下一个任务是将它们链接在一起。为此,在 makefile 的顶部添加以下行:

    time_test.exe : main.obj name.obj time.obj utils.obj 
        link /out:$@ $**

这里的目标是可执行文件,依赖项是四个对象文件。构建可执行文件的命令调用链接工具并使用特殊的语法。$@符号被 make 工具解释为使用目标,因此/out开关实际上将是/out:time_test.out$**符号被 make 工具解释为使用所有依赖项,因此所有依赖项都会被链接。

保存此文件并运行 make 工具。您会发现只有链接工具会被调用,并且它将链接对象文件以创建可执行文件。

最后,添加一个清理项目的规则。提供一种机制来删除编译过程创建的所有文件,并保持项目干净,只留下源文件是一个很好的做法。在链接对象文件的行之后,添加以下行:

    time_test.exe : main.obj name.obj time.obj utils.obj 
        link /out:$@ $** 
 clean : @echo Cleaning the project...    
        del main.obj name.obj time.obj utils.obj utils.pch del time_test.exe

clean目标是一个伪目标:实际上没有文件被创建,因此也没有依赖项。这说明了 make 工具的一个特性:如果您使用目标的名称调用 nmake,该工具将只制作该目标。如果您不指定目标,则该工具将制作 makefile 中提到的第一个目标,在本例中是time_test.exe

clean伪目标有三个命令。第一个命令将Cleaning the project...打印到控制台。这里的@符号告诉 make 实用程序运行命令而不将命令打印到控制台。第二和第三个命令调用命令行工具del来删除文件。现在通过在命令行上输入nmake clean来清理项目,并确认目录中只有头文件、源文件和 makefile。

测试代码

再次运行 make 实用程序,以便构建可执行文件。在命令行上,通过输入**time_test**命令来运行示例。系统会要求您输入您的名字;请这样做,并按 Enter 键。您会发现您的名字、时间和日期被打印在控制台上:

C:\Beginning_C++\Chapter_01>time_test
Your first name? Richard
Richard, the time and date are Tue Sep  6 19:32:23 2016

更改项目

现在您已经有了基本的项目结构,有了一个 makefile,您可以对文件进行更改,并放心,当项目重新构建时,只有更改的文件才会被编译。为了说明这一点,将name.cpp中的print_name函数更改为以更礼貌的方式要求您的名字。更改函数体中的第一行如下所示:

    void print_name() 
    {
 std::cout << "Please type your first name and press [Enter] "; 
        std::string name;

保存文件,然后运行 make 实用程序。这次,只有name.cpp源文件被编译,生成的文件name.obj与现有的对象文件链接。

现在更改name.h头文件并在文件中添加注释:

 // More polite version 
    void print_name();

制作项目。您发现了什么?这次,两个源文件被编译,name.cppmain.cpp,它们与现有的对象文件链接以创建可执行文件。要了解为什么这两个文件被编译,请查看 makefile 中的依赖规则。唯一更改的文件是name.h,并且该文件在name.objmain.obj的依赖列表中,因此,这两个文件将被重新构建。由于这两个文件在time_test.exe的依赖列表中,因此可执行文件也将被重新构建。

总结

本章是对 C的温和但彻底的介绍。您了解了使用这种语言的原因以及如何从一个供应商那里安装编译器。您了解了 C项目的结构,源文件和头文件,以及代码如何通过库共享。您还学会了如何使用 makefile 来维护项目,并通过一个简单的示例,您已经亲身体验了编辑和编译代码。

您已经有了编译器、编辑器和项目管理工具,现在您可以准备学习更多关于 C的细节,从下一章开始学习 C语句和控制应用程序的执行流程。

第二章:理解语言特性

在上一章中,您安装了 C编译器并开发了一个简单的应用程序。您还探索了 C项目的基本结构以及如何管理它们。在本章中,您将深入了解语言,并学习控制代码流的各种语言特性。

编写 C++

在格式和编写代码方面,C是一种非常灵活的语言。它也是一种强类型语言,这意味着有关声明变量类型的规则,您可以利用这些规则使编译器帮助您编写更好的代码。在本节中,我们将介绍如何格式化 C代码以及声明和作用域变量的规则。

使用空格

除了字符串文字之外,您可以自由使用空格(空格,制表符,换行符),并且可以根据需要使用多少。C++语句由分号分隔,因此在以下代码中有三个语句,这些语句将被编译和运行:

    int i = 4; 
    i = i / 2; 
    std::cout << "The result is" << i << std::endl;

整个代码可以写成如下形式:

    int i=4;i=i/2; std::cout<<"The result is "<<i<<std::endl;

有些情况下需要空格(例如,在声明变量时,类型和变量名之间必须有空格),但惯例是尽可能谨慎,以使代码可读。虽然在语言上完全正确,将所有语句放在一行上(如 JavaScript)会使代码几乎完全无法阅读。

如果您对一些更有创意的使代码难以阅读的方法感兴趣,请查看年度国际混淆 C 代码大赛的条目(www.ioccc.org/)。作为 C的鼻祖,IOCCC 中展示的许多 C 的教训也适用于 C代码。

请记住,如果您编写的代码是可行的,它可能会被使用数十年,这意味着您可能需要在编写代码数年后回到代码,并且其他人也将支持您的代码。使您的代码可读不仅是对其他开发人员的礼貌,而且不可读的代码总是可能被替换的目标。

格式化代码

无论您为谁编写代码,最终都会决定您如何格式化代码。有时是有道理的,例如,如果您使用某种形式的预处理来提取代码和定义以创建代码的文档。在许多情况下,强加给您的风格是他人的个人偏好。

Visual C允许您在代码中放置 XML 注释。要做到这一点,您可以使用三斜杠注释(///),然后使用/doc开关编译源文件。这将创建一个名为xdc的中间 XML 文件,其中包含所有三斜杠注释的<doc>根元素。Visual C文档定义了标准的 XML 标记(例如,<param><returns>用于记录函数的参数和返回值)。中间文件使用xdcmake实用程序编译为最终文档 XML 文件。

C++有两种广泛的风格:K&RAllman

Kernighan 和 Ritchie(K&R)写了关于 C 的第一本,也是最有影响力的书(Dennis Ritchie 是 C 语言的作者)。 K&R 风格用于描述该书中使用的格式样式。一般来说,K&R 将代码块的左大括号放在最后一条语句的同一行。如果您的代码有嵌套语句(通常会有),那么这种风格可能会有点令人困惑:

    if (/* some test */) { 
        // the test is true  
        if (/* some other test */) { 
            // second test is true  
        } else { 
            // second test is false    
        } 
    } else { 
        // the test is false  
    }

这种风格通常用于 Unix(和类 Unix)代码。

Allman 风格(以开发人员 Eric Allman 命名)将左大括号放在新行上,因此嵌套示例如下所示:

        if (/* some test */)  
        { 
            // the test is true  
            if (/* some other test */)  
            { 
                // second test is true   
            }  
            else  
            { 
                // second test is false     
            } 
        }  
        else  
        { 
           // the test is false  
        }

Allman 风格通常由微软使用。

请记住,您的代码不太可能以纸质形式呈现,因此 K&R 更紧凑将不会节省任何树木。如果可以选择,您应该选择最可读的风格;对于本书的作者来说,Allman 更可读。

如果有多个嵌套的块,缩进可以让你知道代码位于哪个块中。然而,注释也可以帮助。特别是,如果一个代码块有大量的代码,通常有助于注释代码块的原因。例如,在if语句中,将测试的结果放在代码块中是有帮助的,这样你就知道该块中的变量值是什么。在测试的右括号上放一个注释也是有用的:

    if (x < 0)  
    { 
       // x < 0 
       /* lots of code */ 
    }  // if (x < 0) 

    else  
    { 
       // x >= 0 
       /* lots of code */ 
    }  // if (x < 0)

如果你在右括号上放一个测试的注释,这意味着你有一个搜索项,可以用来找到导致代码块的测试。前面的行使这种注释变得多余,但是当你有许多行代码的代码块,并且有许多层嵌套时,这样的注释是非常有帮助的。

编写语句

语句可以是变量的声明,求值为值的表达式,或者可以是类型的定义。语句也可以是控制结构,以影响代码的执行流程。

语句以分号结束。除此之外,关于语句的格式几乎没有规则。你甚至可以单独使用分号,这被称为空语句。空语句什么也不做,所以有太多分号通常是无害的。

使用表达式

表达式是一系列操作符和操作数(变量或字面值),其结果为某个值。考虑以下内容:

    int i; 
    i = 6 * 7;

在右侧6 * 7是一个表达式,而赋值(从左侧的i到右侧的分号)是一个语句。

每个表达式都是lvaluervalue。你最有可能在错误描述中看到这些关键字。实际上,lvalue 是一个引用某个内存位置的表达式。赋值语句的左侧必须是 lvalue。然而,lvalue 可以出现在赋值语句的左侧或右侧。所有变量都是 lvalues。rvalue 是一个临时项,它的存在不会超过使用它的表达式;它将有一个值,但不能对它进行赋值,因此它只能存在于赋值语句的右侧。字面值是 rvalues。以下是 lvalues 和 rvalues 的一个简单示例:

    int i; 
    i = 6 * 7;

在第二行,i是一个 lvalue,表达式6 * 7的结果是一个 rvalue(42)。以下代码将无法编译,因为左侧有一个 rvalue:

    6 * 7 = i;

广义上讲,通过在表达式后附加分号,表达式变成了语句。例如,以下两者都是语句:

    42;
    std::sqrt(2);

第一行是42的 rvalue,但由于它是临时的,所以没有影响。C++编译器会对其进行优化。第二行调用标准库函数来计算2的平方根。同样,结果是一个 rvalue,值没有被使用,所以编译器会对其进行优化。然而,它说明了一个函数可以被调用而不使用其返回值。虽然对于std::sqrt来说并非如此,但许多函数除了返回值之外还有持久的影响。实际上,函数的整个目的通常是做某事,返回值通常仅用于指示函数是否成功;通常开发人员假设函数会成功,并忽略返回值。

使用逗号运算符

运算符将在本章后面介绍;然而,在这里介绍逗号运算符是有用的。你可以有一系列由逗号分隔的表达式作为单个语句。例如,以下代码在 C++中是合法的:

    int a = 9;
    int b = 4;
    int c;
    c = a + 8, b + 1;

作者本打算输入c = a + 8 / b + 1;,但是他们按错了按键,按了逗号而不是斜杠。本意是让c被赋值为 9 + 2 + 1,即 12。这段代码将编译并运行,变量c将被赋值为 17(a + 8)。原因是逗号将赋值语句的右侧分为两个表达式,a + 8b + 1,并且它使用第一个表达式的值来赋值c。在本章的后面,我们将看到运算符的优先级。然而,值得在这里说的是,逗号的优先级最低,+的优先级高于=,因此语句按照加法的顺序执行:赋值,然后逗号运算符(b + 1的结果被丢弃)。

您可以使用括号来改变优先级以分组表达式。例如,错误输入的代码可能如下所示:

    c = (a + 8, b + 1);

这个语句的结果是:变量c被赋值为 5(或b + 1)。原因是,使用逗号运算符时,表达式从左到右执行,因此表达式组的值是最右边的值。有一些情况,例如for循环的初始化或循环表达式中,您会发现逗号运算符很有用,但正如您在这里看到的,即使有意使用,逗号运算符也会产生难以阅读的代码。

使用类型和变量

类型将在下一章中更详细地介绍,但在这里提供基本信息是有用的。C++是一种强类型语言,这意味着您必须声明您使用的变量的类型。原因是编译器需要知道为变量分配多少内存,并且它可以通过变量的类型来确定这一点。此外,编译器需要知道如何初始化变量,如果没有明确初始化,它需要执行此初始化,而编译器需要知道变量的类型。

C++11 提供了auto关键字,它放宽了强类型的概念,将在下一章中介绍。然而,编译器的类型检查非常重要,因此应尽可能多地使用类型检查。

C++变量可以在代码的任何位置声明,只要它们在使用之前声明即可。您声明变量的位置决定了您如何使用它(这称为变量的作用域)。一般来说,最好在尽可能接近使用变量的地方声明变量,并在最严格的范围内声明。这可以防止名称冲突,在这种情况下,您将不得不添加额外的信息来消除两个或更多个变量的歧义。

您可以并且应该给变量起一个描述性的名称。这样可以使您的代码更易读,更容易理解。C++名称必须以字母字符或下划线开头。它们可以包含除空格之外的字母数字字符,但可以包含下划线。因此,以下名称是有效的:

    numberOfCustomers 
    NumberOfCustomers 
    number_of_customers

C名称区分大小写,前 2048 个字符是有效的。您可以用下划线开头的变量名,但不能使用两个下划线,也不能使用下划线后面跟大写字母(这些被 C保留)。C++还保留了关键字(例如whileif),显然您不能使用类型名称作为变量名称,无论是内置类型名称(intlong等)还是您自己的自定义类型。

您在语句中声明变量,并以分号结束。声明变量的基本语法是指定类型,然后是名称,以及可选的变量初始化。

内置类型必须在使用之前初始化:

    int i; 
    i++;           // C4700 uninitialized local variable 'i' used 
    std::cout << i;

初始化变量基本上有三种方法。您可以赋值,可以调用类型构造函数(类的构造函数将在第六章中定义,),或者可以使用函数语法初始化变量:

    int i = 1; 
    int j = int(2); 
    int k(3);

这三个在 C++中都是合法的,但从风格上讲,第一个更好,因为它更明显:变量是一个整数,叫做i,并且被赋值为 1。第三个看起来令人困惑;它看起来像是一个函数的声明,实际上是在声明一个变量。下一章将展示使用初始化列表语法进行赋值的变化。为什么你会想要这样做的原因将留到那一章。

第六章,将涵盖类,你自己的自定义类型。自定义类型可以被定义为具有默认值,这意味着你可以决定在使用自定义类型的变量之前不初始化它。然而,这会导致性能较差,因为编译器将使用默认值初始化变量,随后你的代码将赋值一个值,导致赋值操作执行两次。

使用常量和文字

每种类型都有一个文字表示。整数将是一个没有小数点的数字表示,如果是有符号整数,文字也可以使用加号或减号符号来表示符号。同样,实数可以有包含小数点的文字值,甚至可以使用科学(或工程)格式,包括指数。C++在代码中指定文字时有各种规则,这些将在下一章中介绍。这里展示了一些文字的例子:

    int pos = +1; 
    int neg = -1; 
    double micro = 1e-6; 
    double unit = 1.; 
    std::string name = "Richard";

请注意,对于unit变量,编译器知道文字是一个实数,因为这个值有一个小数点。对于整数,你可以在你的代码中提供一个十六进制文字,通过在数字前加上0x,所以0x100在十进制中是256。默认情况下,输出流将以十进制打印数字值;然而,你可以在输出流中插入一个操作器来告诉它使用不同的数字基数。默认行为是std::dec,这意味着数字应该以十进制显示,std::oct表示八进制(基数 8)显示,std::hex表示十六进制(基数16)显示。如果你希望看到前缀被打印出来,那么你可以使用流操作器std::showbase(更多细节将在第八章,使用标准库容器中给出)。

C++定义了一些文字。对于bool,逻辑类型,有truefalse常量,其中false是零,true是 1。还有nullptr常量,同样是零,它被用作任何指针类型的无效值。

定义常量

在某些情况下,你会想要提供可以在整个代码中使用的常量值。例如,你可能决定为π声明一个常量。你不应该允许这个值被改变,因为它会改变你代码中的基本逻辑。这意味着你应该将变量标记为常量。当你这样做时,编译器将检查变量的使用,如果它在改变变量值的代码中使用,编译器将发出一个错误:

    const double pi = 3.1415; 
    double radius = 5.0; 
    double circumference = 2 * pi * radius;

在这种情况下,符号pi被声明为常量,所以它不能改变。如果你随后决定改变这个常量,编译器会发出一个错误:

    // add more precision, generates error C3892 
    pi += 0.00009265359;

一旦你声明了一个常量,你可以确保编译器会确保它保持不变。你可以按照以下方式用表达式赋值一个常量:

    #include <cmath> 
    const double sqrtOf2 = std::sqrt(2);

在这段代码中,声明了一个名为sqrtOf2的全局常量,并使用std::sqrt函数赋值。由于这个常量是在函数外声明的,它是文件中的全局变量,并且可以在整个文件中使用。

在上一章中,你学到了声明常量的一种方法是使用#define符号。这种方法的问题在于预处理器进行简单的替换。使用const声明的常量,C++编译器将执行类型检查,以确保常量被适当使用。

你也可以使用const来声明一个将被用作常量表达式的常量。例如,你可以使用方括号语法声明一个数组(更多细节将在第四章,使用内存、数组和指针中给出)。

    int values[5];

这在堆栈上声明了一个包含五个整数的数组,这些项目通过values数组变量访问。这里的5是一个常量表达式。当你在堆栈上声明一个数组时,你必须提供编译器一个常量表达式,以便它知道要分配多少内存,这意味着数组的大小必须在编译时知道。(你可以分配一个只在运行时知道大小的数组,但这需要动态内存分配,在第四章中有解释,使用内存、数组和指针。)在 C++中,你可以声明一个常量来执行以下操作:

    const int size = 5;  
    int values[size];

在代码的其他地方,当你访问values数组时,你可以使用size常量来确保你不会访问数组末尾之后的项目。由于size变量只在一个地方声明,如果你需要在以后的阶段更改数组的大小,你只需要在一个地方进行更改。

const关键字也可以用于指针和引用(见第四章,使用内存、数组和指针)和对象(见第六章,);通常,你会看到它用于函数的参数(见第五章,使用函数)。这用于让编译器帮助确保指针、引用和对象被按照你的意图使用。

使用常量表达式

C++11 引入了一个名为constexpr的关键字。这个关键字应用于一个表达式,表示该表达式应该在编译时而不是在运行时求值:

    constexpr double pi = 3.1415; 
    constexpr double twopi = 2 * pi;

这类似于初始化使用const关键字声明的常量。然而,constexpr关键字也可以应用于返回可以在编译时求值的值的函数,因此这允许编译器优化代码:

    constexpr int triang(int i) 
    { 
       return (i == 0) ? 0 : triang(i - 1) + i;
    }

在这个例子中,函数triang递归地计算三角数。代码使用了条件运算符。在括号中,测试函数参数是否为零,如果是,则函数返回零,实际上结束了递归,并将函数返回给原始调用者。如果参数不为零,则返回值是参数和减小参数的triang调用的返回值的和。

当你在代码中使用文字调用这个函数时,它可以在编译时求值。constexpr是对编译器的指示,检查函数的使用情况,看它是否可以在编译时确定参数。如果是这样,编译器可以求值返回值,并比在运行时调用函数更有效地生成代码。如果编译器无法在编译时确定参数,函数将被正常调用。用constexpr关键字标记的函数只能有一个表达式(因此在triang函数中使用条件运算符?:)。

使用枚举

提供常量的最后一种方法是使用enum变量。实际上,enum是一组命名常量,这意味着你可以将enum用作函数的参数。例如:

    enum suits {clubs, diamonds, hearts, spades};

这定义了一个名为suits的枚举,其中包含了一副牌中的花色的命名值。枚举是一个整数类型,默认情况下编译器会假定为int,但你可以在声明中指定整数类型来改变这一点。由于卡牌花色只有四种可能的值,使用int(通常为4字节)是一种浪费内存,我们可以使用char(一个字节)来代替。

    enum suits : char {clubs, diamonds, hearts, spades};

当您使用枚举值时,您可以只使用名称;但是,通常会使用枚举的名称对其进行范围限定,使代码更易读:

    suits card1 = diamonds; 
    suits card2 = suits::diamonds;

这两种形式都是允许的,但后一种形式更明确地表示值是从枚举中获取的。为了强制开发人员指定作用域,可以应用关键字class

    enum class suits : char {clubs, diamonds, hearts, spades};

有了这个定义和前面的代码,声明card2的行将编译,但声明card1的行将不会。使用作用域的enum,编译器将枚举视为新类型,并且没有从新类型到整数变量的内置转换。例如:

    suits card = suits::diamonds; 
    char c = card + 10; // errors C2784 and C2676

enum类型是基于char的,但当您将suits变量定义为带有class的作用域时,第二行将无法编译。如果枚举被定义为不带有class的作用域,则枚举值和char之间存在内置转换。

默认情况下,编译器将为第一个枚举器赋值为 0,然后递增后续枚举器的值。因此,suits::diamonds的值将为 1,因为它是suits中的第二个值。您也可以自己分配值:

    enum ports {ftp=21, ssh, telnet, smtp=25, http=80};

在这种情况下,ports::ftp的值为 21,ports::ssh的值为 22(21 递增),ports::telnet为 22,ports::smtp为 25,ports::http为 80。

通常,枚举的目的是在您的代码中提供命名的符号,它们的值并不重要。suits::hearts分配什么值有关系吗?通常的意图是确保它与其他值不同。在其他情况下,这些值很重要,因为它们是向其他函数提供值的一种方式。

枚举在switch语句中很有用(稍后会看到),因为命名值使其比仅使用整数更清晰。您还可以将枚举用作函数的参数,从而限制通过该参数传递的值:

    void stack(suits card) 
    { 
        // we know that card is only one of four values 
    }

声明指针

由于我们正在讨论变量的使用,因此值得解释用于定义指针和数组的语法,因为存在一些潜在的陷阱。第四章,使用内存、数组和指针,将更详细地介绍这一点,因此我们只是介绍语法,以便您熟悉它。

在 C++中,您将使用类型化指针访问内存。类型指示指向的内存中保存的数据类型。因此,如果指针是(4 字节)整数指针,它将指向可以用作整数的四个字节。如果整数指针被递增,那么它将指向下一个四个字节,这些字节可以用作整数。

如果您发现指针令人困惑,不要担心。第四章,使用内存、数组和指针,将更详细地解释这一点。此时介绍指针的目的是让您了解语法。

在 C++中,指针使用*符号声明,并使用&运算符访问内存地址:

    int *p; 
    int i = 42; 
    p = &i;

第一行声明一个变量p,用于保存整数的内存地址。第二行声明一个整数并为其分配一个值。第三行将一个值分配给指针p,使其成为刚刚声明的整数变量的地址。需要强调的是,p的值不是42;它将是存储42值的内存地址。

请注意声明中变量名称上的*。这是常见的约定。原因是,如果您在一个语句中声明多个变量,则*仅适用于直接变量。例如:

    int* p1, p2;

最初看起来好像您在声明两个整数指针。但是,这行并不是这样做的;它只声明了一个名为p1的整数指针。第二个变量是一个名为p2的整数。前一行等同于以下内容:

    int *p1;  
    int p2;

如果您希望在一条语句中声明两个整数,那么应该这样做:

    int *p1, *p2;

使用命名空间

命名空间为您提供了一种模块化代码的机制。命名空间允许您使用作用域解析运算符为您的类型、函数和变量打上唯一的标签,以便您可以给出完全限定的名称。优点是您确切地知道将调用哪个项目。缺点是,使用完全限定的名称实际上关闭了 C++的参数相关查找机制,对于重载函数,编译器将根据传递给函数的参数选择最佳匹配的函数。

定义命名空间很简单:您使用namespace关键字和您给它的名称来装饰类型、函数和全局变量。在以下示例中,两个函数在utilities命名空间中定义:

    namespace utilities 
    { 
        bool poll_data() 
        { 
            // code that returns a bool 
        } 
        int get_data() 
        { 
            // code that returns an integer 
        } 
    }

在右括号后不要使用分号。

现在当您使用这些符号时,您需要用命名空间限定名称:

    if (utilities::poll_data()) 
    { 
        int i = utilities::get_data(); 
        // use i here... 
    }

命名空间声明可能只声明函数,此时实际函数必须在其他地方定义,并且您需要使用限定名称:

    namespace utilities 
    { 
        // declare the functions 
        bool poll_data(); 
        int get_data(); 
    } 

    //define the functions 
    bool utilities::poll_data() 
    { 
        // code that returns a bool 
    } 

    int utilities::get_data() 
    { 
       // code that returns an integer 
    }

命名空间的一个用途是对代码进行版本控制。代码的第一个版本可能具有一个不在功能规范中的副作用,从技术上讲是一个错误,但一些调用者会使用它并依赖它。当您更新代码以修复错误时,您可能决定允许调用者选择使用旧版本,以便他们的代码不会出错。您可以使用命名空间来实现这一点:

    namespace utilities 
    { 
        bool poll_data(); 
        int get_data(); 

        namespace V2 
        { 
            bool poll_data(); 
            int get_data(); 
            int new_feature(); 
        } 
    }

现在想要特定版本的调用者可以调用完全限定的名称,例如,调用者可以使用utilities::V2::poll_data来使用更新版本,使用utilities::poll_data来使用旧版本。当特定命名空间中的项目调用同一命名空间中的项目时,它不必使用限定名称。因此,如果new_feature函数调用get_data,将调用utilities::V2::get_data。重要的是要注意,要声明嵌套命名空间,您必须手动进行嵌套(如此处所示);您不能简单地声明一个名为utilities::V2的命名空间。

前面的示例是这样编写的,以便代码的第一个版本将使用utilities命名空间进行调用。C++11 提供了一个名为内联命名空间的设施,允许您定义嵌套命名空间,但允许编译器在执行参数相关查找时将项目视为在父命名空间中:

    namespace utilities 
    { 
        inline namespace V1 
        { 
            bool poll_data(); 
            int get_data(); 
        } 

        namespace V2 
        { 
            bool poll_data(); 
            int get_data(); 
            int new_feature(); 
        } 
    }

现在要调用get_data的第一个版本,您可以使用utilities::get_datautilities::V1::get_data

完全限定的名称可能会使代码难以阅读,特别是如果您的代码只使用一个命名空间。在这里,您有几个选项可以帮助。您可以放置一个using语句来指示可以在指定的命名空间中声明的符号可以在不使用完全限定名称的情况下使用:

    using namespace utilities; 
    int i = get_data(); 
    int j = V2::get_data();

您仍然可以使用完全限定的名称,但此语句允许您放宽要求。请注意,嵌套命名空间是命名空间的成员,因此前面的using语句意味着您可以使用utilities::V2::get_dataV2::get_data调用get_data的第二个版本。如果使用未限定名称,则意味着您将调用utilities::get_data

命名空间可以包含许多项目,您可能决定只想放宽对其中一些项目的完全限定名称的使用。要做到这一点,使用using并给出项目的名称:

    using std::cout; 
    using std::endl; 
    cout << "Hello, World!" << endl;

此代码表示,每当使用cout时,它都指的是std::cout。您可以在函数内部使用using,也可以将其放在文件范围,并使意图全局化到文件。

您不必在一个地方声明命名空间,可以在几个文件中声明它。以下内容可以与先前对utilities的声明不同的文件中:

    namespace utilities 
    { 
        namespace V2 
        { 
            void print_data(); 
        } 
    }

print_data函数仍然是utilities::V2命名空间的一部分。

你也可以在命名空间中放置一个#include,在这种情况下,头文件中声明的项目现在将成为命名空间的一部分。具有c前缀的标准库头文件(例如cmathcstdlibctime)通过在std命名空间中包含适当的 C 头文件来访问 C 运行时函数。

命名空间的一个巨大优势是能够使用可能是常见的名称来定义你的项目,但对于不知道命名空间名称的其他代码是隐藏的。命名空间意味着这些项目仍然可以通过完全限定的名称在你的代码中使用。然而,这仅在你使用唯一的命名空间名称时才有效,而很可能的情况是,命名空间名称越长,它就越有可能是唯一的。Java 开发人员通常使用 URI 来命名他们的类,你也可以决定做同样的事情:

    namespace com_packtpub_richard_grimes 
    { 
        int get_data(); 
    }

问题在于完全限定的名称变得相当长:

    int i = com_packtpub_richard_grimes::get_data();

你可以通过使用别名来解决这个问题:

    namespace packtRG = com_packtpub_richard_grimes; 
    int i = packtRG::get_data();

C++允许你定义一个没有名称的命名空间,一个匿名命名空间。如前所述,命名空间允许你防止在多个文件中定义的代码之间发生名称冲突。如果你打算在只有一个文件中使用这样的名称,你可以定义一个唯一的命名空间名称。然而,如果你必须为多个文件做同样的事情,这可能会变得乏味。没有名称的命名空间具有特殊含义,即它具有内部链接,也就是说,这些项目只能在当前翻译单元,当前文件中使用,而不能在任何其他文件中使用。

没有在命名空间中声明的代码将成为global命名空间的成员。你可以在没有命名空间名称的情况下调用代码,但你可能希望明确指出该项目在global命名空间中使用作用域解析运算符:

    int version = 42; 

    void print_version() 
    { 
        std::cout << "Version = " << ::version << std::endl; 
    }

C++变量的作用域

在上一章中,你看到编译器会将你的源文件编译为称为翻译单元的单独项目。编译器将确定你声明的对象和变量以及你定义的类型和函数,一旦声明,你就可以在声明的范围内在随后的代码中使用任何这些。在最广泛的意义上,你可以通过在一个头文件中声明一个项目来在全局范围内声明它,该头文件将被项目中的所有源文件使用。如果你不使用命名空间,当你使用这样的全局变量时,将它们命名为全局命名空间的一部分通常是明智的:

    // in version.h 
    extern int version; 

    // in version.cpp 
    #include "version.h"  
    version = 17; 

    // print.cpp 
    #include "version.h" 
    void print_version() 
    { 
        std::cout << "Version = " << ::version << std::endl; 
    }

这段代码有两个 C++源文件(version.cppprint.cpp)和一个头文件(version.h),两个源文件都包含了这个头文件。头文件声明了全局变量version,可以被两个源文件使用;它声明了这个变量,但没有定义它。实际的变量在version.cpp中定义和初始化;编译器将在这里为变量分配内存。在头文件中声明的extern关键字指示编译器version具有外部链接,即该名称在变量定义所在的文件之外的文件中可见。version变量在print.cpp源文件中使用。在这个文件中,作用域解析运算符(::)在没有命名空间名称的情况下使用,因此表明变量version在全局命名空间中。

你还可以声明只在当前翻译单元中使用的项目,方法是在使用之前在源文件中声明它们(通常在文件的顶部)。这产生了一定程度的模块化,并允许你隐藏来自其他源文件的实现细节。例如:

    // in print.h 
    void usage(); 

    // print.cpp 
    #include "version.h" 
    std::string app_name = "My Utility"; 
    void print_version() 
    { 
       std::cout << "Version = " << ::version << std::endl; 
    } 

    void usage() 
    { 
       std::cout << app_name << " "; 
       print_version(); 
    }

print.h头文件包含了print.cpp文件中代码的接口。只有在头文件中声明的函数才能被其他源文件调用。调用者不需要知道usage函数的实现,正如你在这里看到的,它是使用一个名为print_version的函数调用来实现的,该函数只能在print.cpp中的代码中使用。变量app_name在文件范围内声明,因此只能被print.cpp中的代码访问。

如果另一个源文件在文件范围内声明了一个名为app_namestd::string类型的变量,那么该文件将编译通过,但在链接目标文件时链接器会抱怨。原因是链接器会看到同一个变量在两个地方被定义,它不知道该使用哪一个。

函数也定义了一个作用域;在函数内定义的变量只能通过该名称访问。函数的参数也被包括在函数内部作为变量,因此当你声明其他变量时,你必须使用不同的名称。如果一个参数没有标记为const,那么你可以在函数中改变参数的值。

在函数内部,只要在使用变量之前声明它们,就可以在任何地方声明变量。花括号({})用于定义代码块,它们还定义了局部作用域;如果在代码块内声明变量,那么只能在那里使用它。这意味着你可以在代码块外声明同名变量,编译器会使用最接近访问范围的变量。

在完成本节之前,重要的是要提到 C++ 存储类的一个方面。在函数中声明的变量意味着编译器会在为函数创建的堆栈帧上为变量分配内存。当函数结束时,堆栈帧被销毁,内存被回收。这意味着在函数返回后,任何局部变量中的值都会丢失;当再次调用函数时,变量会被重新创建并再次初始化。

C++提供了static关键字来改变这种行为。static关键字意味着变量在程序启动时就像在全局范围声明的变量一样被分配。将static应用于在函数中声明的变量意味着该变量具有内部链接,也就是说,编译器限制对该函数的访问:

    int inc(int i) 
    { 
        static int value; 
        value += i; 
        return value; 
    } 

    int main() 
    { 
        std::cout << inc(10) << std::endl; 
        std::cout << inc(5) << std::endl; 
    }

默认情况下,编译器会将静态变量初始化为0,但你可以提供一个初始化值,在变量首次分配时将使用该值。当程序启动时,value变量将在调用main函数之前初始化为0。第一次调用inc函数时,value变量增加到 10,这个值被函数返回并打印到控制台。当inc函数返回时,value变量被保留,所以当再次调用inc函数时,value变量增加了5,变为15

使用运算符

运算符用于从一个或多个操作数计算值。下表将所有具有相同优先级的运算符分组,并列出它们的结合性。表中越高的位置,表示在表达式中运算符的执行优先级越高。如果表达式中有多个运算符,编译器会先执行优先级更高的运算符,然后再执行优先级较低的运算符。如果一个表达式包含相同优先级的运算符,那么编译器将使用结合性来决定操作数是与其左边还是右边的运算符分组。

这个表格中存在一些歧义。一对括号可以表示函数调用或转换,在表格中列出为 function()cast();在您的代码中,您将简单地使用 ()+- 符号既用于表示符号(一元加和一元减,在表格中表示为 +x-x),也用于加法和减法(在表格中表示为 +-)。& 符号表示取地址(在表格中列为 &x)或按位 AND(在表格中列为 &)。最后,后缀递增和递减运算符(在表格中列为 x++x--)的优先级高于前缀等价物(列为 ++x--x)。

优先级和结合性 运算符
1: 无结合性 ::
2: 从左到右的结合性 .-> [] function() {} x++ x-- typeid const_cast dynamic_cast reinterpret_cast static_cast
3: 从右到左的结合性 sizeof ++x --x ~ ! -x +x &x * new delete cast()
4: 从左到右的结合性 .*->*
5: 从左到右的结合性 * / %
6: 从左到右的结合性 + -
7: 从左到右的结合性 << >>
8: 从左到右的结合性 < > <= >=
9: 从左到右的结合性 == !=
10: 从左到右的结合性 &
11: 从左到右的结合性 ^
12: 从左到右的结合性 &#124;
13: 从左到右的结合性 &&
14: 从左到右的结合性 &#124;&#124;
15: 从右到左的结合性 ? :
16: 从右到左的结合性 = *= /= %= += -= <<= >>= &= &#124;= ^=
17: 从右到左的结合性 throw
18: 从左到右的结合性 ,

例如,看下面的代码:

    int a = b + c * d;

这被解释为首先执行乘法,然后执行加法。写相同代码的更清晰的方法是:

    int a = b + (c * d);

原因是 * 的优先级高于 +,因此首先执行乘法,然后执行加法:

    int a = b + c + d;

在这种情况下,+ 运算符具有相同的优先级,高于赋值的优先级。由于 + 具有从左到右的结合性,该语句的解释如下:

    int a = ((b + c) + d);

也就是说,首先执行 bc 的加法,然后将结果加到 d 上,然后将这个结果用于赋值给 a。这可能看起来不重要,但请记住,加法可能是在函数调用之间进行的(函数调用的优先级高于 +):

    int a = b() + c() + d();

这意味着这三个函数按照从左到右的结合性的顺序被调用,即 bcd,然后它们的返回值被相加。这可能很重要,因为 d 可能依赖于其他两个函数改变的全局数据。

如果您使用括号将表达式分组,可以使您的代码更易读和理解。编写 b + (c * d) 可以立即清楚地知道哪个表达式首先执行,而 b + c * d 意味着您必须知道每个运算符的优先级。

内置运算符是重载的,也就是说,无论使用哪种内置类型的操作数,都使用相同的语法。操作数必须是相同的类型;如果使用不同的类型,编译器将执行一些默认转换,但在其他情况下(特别是在操作不同大小的类型时),您将不得不执行一个转换来明确表示您的意思。下一章将更详细地解释这一点。

探索内置运算符

C++提供了广泛的内置运算符;大多数是算术或逻辑运算符,将在本节中介绍。强制转换运算符将在下一章中介绍;内存运算符将在第四章中介绍,处理内存、数组和指针,对象相关的运算符将在第六章中介绍,

算术运算符

算术运算符+-/*%需要很少的解释,除了除法和取模运算符。所有这些运算符都作用于整数和实数类型,除了%,它只能与整数类型一起使用。如果混合类型(比如,将整数加到浮点数),那么编译器将执行自动转换,如下一章所述。除法运算符/对浮点变量的行为与预期相符:它产生两个操作数的除法结果。当你对两个整数a / b进行除法运算时,结果是被除数(a)中除数(b)的整数部分。取模运算符%得到除法的余数。因此,对于任何整数b(非零),可以说,整数a可以表示如下:

    (a / b) * b + (a % b)

请注意,取模运算符只能用于整数。如果要获得浮点数除法的余数,可以使用标准函数std:;remainder

在使用整数进行除法时要小心,因为小数部分会被舍弃。如果需要小数部分,则可能需要将数字显式转换为实数。例如:

    int height = 480; 
    int width = 640; 
    float aspect_ratio = width / height;

这给出了一个纵横比为1,而实际应为1.3333(或4:3)。为确保执行浮点数除法,而不是整数除法,可以将被除数或除数(或两者)转换为浮点数,如下一章所述。

递增和递减运算符

这些运算符有两个版本,前缀和后缀。顾名思义,前缀意味着运算符放在操作数的左边(例如,++i),后缀运算符放在右边(i++)。++运算符将递增操作数,--运算符将递减操作数。前缀运算符意味着“返回操作之后的值”,后缀运算符意味着“返回操作之前的值”。因此,以下代码将递增一个变量并将其用于赋值另一个变量:

    a = ++b;

这里使用了前缀运算符,所以变量b被递增,变量a被赋值为b递增后的值。另一种表达方式是:

    a = (b = b + 1);

以下代码使用后缀运算符赋值:

    a = b++;

这意味着变量b被递增,但变量a被赋值为b递增前的值。另一种表达方式是:

    int t; 
    a = (t = b, b = b + 1, t);

请注意,此语句使用逗号运算符,因此a被赋值为右侧表达式中临时变量t的值。

递增和递减运算符可以应用于整数和浮点数。这些运算符也可以应用于指针,其中它们具有特殊含义。当你递增一个指针变量时,它的意思是递增指针的大小

位运算符

整数可以被视为一系列位,01。位运算符作用于这些位,与另一个操作数中相同位置的位进行比较。有符号整数使用一位来表示符号,但位运算符作用于整数的每一位,因此通常只有在无符号整数上使用它们才有意义。在以下内容中,所有类型都标记为unsigned,因此它们被视为没有符号位。

&运算符是按位 AND,这意味着将左操作数中的每个位与右操作数中相同位置的位进行比较。如果两者都为 1,则相同位置的结果位将为 1;否则,结果位为零:

    unsigned int a = 0x0a0a; // this is the binary 0000101000001010 
    unsigned int b = 0x00ff; // this is the binary 0000000000001111 
    unsigned int c = a & b;  // this is the binary 0000000000001010 
    std::cout << std::hex << std::showbase << c << std::endl;

在此示例中,使用位&0x00ff具有与提供掩码相同的效果,该掩码掩盖了除最低字节之外的所有内容。

按位 OR 运算符|将在相同位置的两个位中的任一个或两个位为 1 时返回值 1,并且仅当两者都为 0 时返回值 0:

    unsigned int a = 0x0a0a; // this is the binary 0000101000001010 
    unsigned int b = 0x00ff; // this is the binary 0000000000001111 
    unsigned int c = a & b;  // this is the binary 0000101000001111 
    std::cout << std::hex << std::showbase << c << std::endl;

&运算符的一个用途是查找特定位(或特定位的集合)是否设置:

    unsigned int flags = 0x0a0a; // 0000101000001010 
    unsigned int test = 0x00ff;  // 0000000000001111 

    // 0000101000001111 is (flags & test) 
    if ((flags & test) == flags)  
    { 
        // code for when all the flags bits are set in test 
    } 
    if ((flags & test) != 0) 
    { 
        // code for when some or all the flag bits are set in test  
    }

flags变量具有我们需要的位,test变量是我们正在检查的值。值(flags&test)将仅具有flags变量中也在flags中设置的test变量中的那些位。因此,如果结果非零,则意味着test中至少有一个位也在flags中设置;如果结果与flags变量完全相同,则flags中的所有位都在test中设置。

异或运算符^用于测试位不同的情况;如果操作数中的位不同,则结果位为1,如果它们相同,则为0。异或运算可以用于翻转特定位:

    int value = 0xf1; 
    int flags = 0x02; 
    int result = value ^ flags; // 0xf3 
    std::cout << std::hex << result << std::endl;

最后一个位运算符是位取反。该运算符应用于单个整数操作数,并返回一个值,其中每个位都是操作数中相应位的补码;因此,如果操作数位为 1,则结果中的位为 0,如果操作数中的位为 0,则结果中的位为 1。请注意,所有位都会被检查,因此您需要了解整数的大小。

布尔运算符

==运算符测试两个值是否完全相同。如果测试两个整数,则测试是显而易见的;例如,如果x为 2,y为 3,则x == y显然为false。但是,即使您认为两个实数可能不相同:

    double x = 1.000001 * 1000000000000; 
    double y = 1000001000000; 
    if (x == y) std::cout << "numbers are the same";

double类型是一个浮点类型,占用 8 个字节,但这对于此处使用的精度来说是不够的;存储在x变量中的值为1000000999999.9999(保留四位小数)。

!=运算符测试两个值是否不为真。运算符><测试两个值,以查看左操作数是否大于或小于右操作数,>=运算符测试左操作数是否大于或等于右操作数,<=运算符测试左操作数是否小于或等于右操作数。这些运算符可以在if语句中使用,类似于在前面的示例中使用==。使用运算符的表达式返回bool类型的值,因此您可以使用它们来为布尔变量分配值:

    int x = 10; 
    int y = 11; 
    bool b = (x > y); 
    if (b) std::cout << "numbers same"; 
    else   std::cout << "numbers not same";

赋值运算符(=)的优先级高于大于(>=)运算符,但我们已经使用括号明确表示在使用变量之前对其进行测试。您可以使用运算符来否定逻辑值。因此,使用先前获得的b的值,您可以编写以下内容:

    if (!b) std::cout << "numbers not same"; 
    else    std::cout << "numbers same";

您可以使用&&(AND)和||(OR)运算符组合两个逻辑表达式。具有&&运算符的表达式仅在两个操作数都为true时才为true,而具有||运算符的表达式仅在两个操作数中的任一个或两个操作数都为true时才为true

    int x = 10, y = 10, z = 9; 
    if ((x == y) || (y < z)) 
        std::cout << "one or both are true";

此代码涉及三个测试;第一个测试xy变量是否具有相同的值,第二个测试变量y是否小于z,然后有一个测试,看看第一个两个测试中的任一个或两个是否为true

||表达式中,第一个操作数(x==y)为true,则无论右操作数的值如何,总逻辑表达式都将为true。因此,没有必要测试第二个表达式。同样,在&&表达式中,如果第一个操作数为false,则整个表达式必须为false,因此无需测试表达式的右侧部分。编译器将为您提供执行此短路的代码:

    if ((x != 0) && (0.5 > 1/x))  
    { 
        // reciprocal is less than 0.5 
    }

此代码测试x的倒数是否小于 0.5(或者x大于 2)。如果x变量的值为 0,则测试1/x是一个错误,但在这种情况下,表达式永远不会被执行,因为&&的左操作数为false

位移操作符

位移操作符将左操作数整数中的位向指定方向中的右操作数指定的位数移动。向左移动一位将数字乘以 2,向右移动一位将数字除以 2。在以下示例中,一个 2 字节整数进行了位移:

    unsigned short s1 = 0x0010; 
    unsigned short s2 = s1 << 8; 
    std::cout << std::hex << std::showbase; 
    std::cout << s2 << std::endl; 
    // 0x1000  
    s2 = s2 << 3; 
    std::cout << s2 << std::endl; 
    // 0x8000

在此示例中,变量s1的第五位被设置为0x0010或 16。变量s2具有此值,向左移动 8 位,因此单个位移动到第 13 位,并且底部 8 位全部设置为 0(0x10000或 4,096)。这意味着0x0010已乘以 2⁸,或 256,得到0x1000。接下来,该值再向左移动 3 位,结果为0x8000;最高位被设置。

该运算符丢弃任何溢出的位,因此如果设置了最高位并将整数向左移动一位,那么最高位将被丢弃:

    s2 = s2 << 1; 
    std::cout << s2 << std::endl; 
    // 0

最后再向左移动一位将得到一个值为 0。

重要的是要记住,当与流一起使用时,操作符<<表示插入到流中,当与整数一起使用时,它表示位移

赋值运算符

赋值运算符=将左边的 lvalue(变量)赋予右边 rvalue(变量或表达式)的结果:

    int x = 10; 
    x = x + 10;

第一行声明一个整数并将其初始化为 10。第二行通过添加另外 10 来更改变量,所以现在变量x的值为 20。这是赋值。C++允许您根据变量的值使用简化的语法更改变量的值。前面的行可以写成如下形式:

    int x = 10; 
    x += 10;

这样的增量运算符(以及减量运算符)可以应用于整数和浮点类型。如果该运算符应用于指针,则操作数指示指针更改了多少个整体项目地址。例如,如果int为 4 字节,并且您将10添加到int指针,则实际指针值将增加 40(10 乘以 4 字节)。

除了增量(+=)和减量(-=)赋值之外,还可以进行乘法(*=),除法(/=)和取余(%=)的赋值。除了最后一个(%=)之外,所有这些都可以用于浮点类型和整数。取余赋值只能用于整数。

您还可以对整数执行位赋值操作:左移(<<=),右移(>>=),按位与(&=),按位或(|=),按位异或(^=)。通常只有对无符号整数应用这些操作才有意义。因此,通过以下两行可以进行乘以八的操作:

    i *= 8; 
    i <<= 3;

控制执行流程

C++提供了许多测试值和循环执行代码的方法。

使用条件语句

最常用的条件语句是if。在其最简单的形式中,if语句在一对括号中接受一个逻辑表达式,并紧接着执行该条件为true的语句:

    int i; 
    std::cin >> i; 
    if (i > 10) std::cout << "much too high!" << std::endl;

您还可以使用else语句来捕获条件为false的情况:

    int i; 
    std::cin >> i; 
    if (i > 10) std::cout << "much too high!" << std::endl; 
    else        std::cout << "within range" << std::endl;

如果要执行多个语句,可以使用大括号({})来定义一个代码块。

条件是一个逻辑表达式,C++会将数值类型转换为bool,其中 0 是false,而非 0 是true。如果你不小心,这可能是一个难以注意到的错误源,而且可能会产生意想不到的副作用。考虑以下代码,它要求从控制台输入,然后测试用户是否输入了-1:

    int i; 
    std::cin >> i; 
    if (i == -1) std::cout << "typed -1" << endl; 
    std::cout << "i = " << i << endl;

这是刻意的,但你可能会在循环中要求值,然后对这些值执行操作,除非用户输入-1,此时循环结束。如果你误输入,你可能会得到以下代码:

    int i; 
    std::cin >> i; 
    if (i = -1) std::cout << "typed -1" << endl; 
    std::cout << "i = " << i << endl;

在这种情况下,赋值运算符(=)被用来代替相等运算符(==)。只有一个字符的差别,但这段代码仍然是正确的 C++,编译器也乐意编译它。

结果是,无论你在控制台输入什么,变量i都被赋值为-1,而且由于-1 不是零,if语句中的条件是true,因此执行了语句的真分支。由于变量已经被赋值为-1,这可能会改变你代码中的逻辑。避免这个 bug 的方法是利用赋值的要求,左侧必须是一个左值。按照以下方式进行测试:

    if (-1 == i) std::cout << "typed -1" << endl;

在这里,逻辑表达式是(-1 == i),由于==运算符是可交换的(操作数的顺序不重要;你会得到相同的结果),这与你在前面的测试中打算的完全相同。然而,如果你误输入了运算符,你会得到以下结果:

    if (-1 = i) std::cout << "typed -1" << endl;

在这种情况下,赋值在左侧有一个 rvalue,这将导致编译器发出错误(在 Visual C++中是C2106 '=' : left operand must be l-value)。

你可以在if语句中声明一个变量,变量的作用域在语句块中。例如,一个返回整数的函数可以这样调用:

    if (int i = getValue()) {    
        // i != 0    // can use i here  
    } else {    
        // i == 0    // can use i here  
    }

虽然这在 C++中是完全合法的,但你可能会想这样做的原因很少。

在某些情况下,条件运算符?:可以代替if语句。该运算符执行?运算符左侧的表达式,如果条件表达式为true,则执行:右侧的表达式。如果条件表达式为false,则执行:右侧的表达式。运算符执行的表达式提供了条件运算符的返回值。

例如,以下代码确定了两个变量ab的最大值:

    int max; 
    if (a > b) max = a; 
    else       max = b;

这可以用以下单一语句来表达:

    int max = (a > b) ? a : b;

主要选择是在代码中哪个更可读。显然,如果赋值表达式很大,最好将它们分成几行放在if语句中。然而,在其他语句中使用条件语句也是有用的。例如:

    int number;  
    std::cin  >> number; 
    std::cout << "there " 
              << ((number == 1) ? "is " : "are ")  
              << number << " item"            
              << ((number == 1) ? "" : "s") 
              << std::endl;

这段代码确定变量number是否为 1,如果是,则在控制台上打印there is 1 item。这是因为在两个条件中,如果number变量的值为 1,测试是true,并且使用第一个表达式。请注意,整个运算符周围有一对括号。原因是流<<运算符被重载了,你希望编译器选择接受字符串的版本,这是运算符返回的类型,而不是bool,这是表达式(number == 1)的类型。

如果条件运算符返回的值是一个左值,那么你可以将其用在赋值的左侧。这意味着你可以写出以下相当奇怪的代码:

    int i = 10, j = 0; 
    ((i < j) ? i : j) = 7; 
    // i is 10, j is 7 

    i = 0, j = 10; 
    ((i < j) ? i : j) = 7; 
    // i is 7, j is 10

条件运算符检查i是否小于j,如果是,则将一个值赋给i;否则,将j赋值为该值。这段代码很简洁,但缺乏可读性。在这种情况下,最好使用if语句。

选择

如果您想测试变量是否是多个值中的一个,使用多个if语句会变得很麻烦。C++的switch语句更好地实现了这一目的。基本语法如下所示:

    int i; 
    std::cin >> i; 
    switch(i) 
    { 
        case 1:  
            std::cout << "one" << std::endl; 
            break; 
        case 2:  
            std::cout << "two" << std::endl; 
            break; 
        default: 
            std::cout << "other" << std::endl; 
    }

每个case本质上是一个标签,用于指定如果所选变量是指定值,则要运行的特定代码。default子句用于不存在case的值。您不必有default子句,这意味着您只测试指定的情况。default子句可以是最常见的情况(在这种情况下,case过滤掉不太可能的值),也可以是异常值(在这种情况下,case处理最可能的值)。

switch语句只能测试整数类型(包括enum),并且只能测试常量。char类型是一个整数,这意味着您可以在case项中使用字符,但只能使用单个字符;您不能使用字符串:

    char c; 
    std::cin >> c; 
    switch(c) 
    { 
        case 'a':  
            std::cout << "character a" << std::endl; 
            break; 
        case 'z':   
            std::cout << "character z" << std::endl; 
            break; 
        default: 
            std::cout << "other character" << std::endl; 
    }

break语句表示执行case的语句结束。如果您不指定它,执行将穿透并且将执行以下case语句,即使它们已被指定为不同的case

    switch(i) 
    { 
        case 1:  
            std::cout << "one" << std::endl; 
            // fall thru 
        case 2:  
            std::cout << "less than three" << std::endl; 
            break; 
        case 3:  
            std::cout << "three" << std::endl; 
            break; 
        case 4: 
            break; 
            default: 
            std::cout << "other" << std::endl; 
    }

此代码显示了break语句的重要性。值为 1 将同时打印oneless than three到控制台,因为执行穿透到前面的case,即使该case是另一个值。

通常每个case都有不同的代码,因此您最常会在case结束时使用break。如果不小心忽略了break,这将导致异常行为。在有意省略break语句时,最好记录您的代码,以便知道如果缺少break,那很可能是一个错误。

您可以为每个case提供零个或多个语句。如果有多个语句,则它们都将执行该特定case。如果您不提供语句(例如在此示例中的case 4),那么这意味着不会执行任何语句,甚至不会执行default子句中的语句。

break语句表示跳出此代码块,并且在whilefor循环语句中也是如此。还有其他方法可以跳出switchcase可以调用return来结束声明switch的函数;它可以调用goto跳转到一个标签,或者它可以调用throw抛出一个异常,该异常将被switch之外的异常处理程序捕获,甚至是函数之外。

到目前为止,case是按数字顺序排列的。这不是必需的,但这样做会使代码更易读,显然,如果您想穿透case语句(就像这里的case 1一样),您应该注意case项的顺序。

如果您需要在case处理程序中声明临时变量,则必须使用大括号定义代码块,这将使变量的作用域局限于该代码块。当然,您可以在任何case处理程序中使用在switch语句之外声明的任何变量。

由于枚举常量是整数,您可以在switch语句中测试enum

    enum suits { clubs, diamonds, hearts, spades }; 

    void print_name(suits card) 
    { 
        switch(card) 
        { 
            case suits::clubs: 
                std::cout << "card is a club"; 
                break; 
            default: 
                std::cout << "card is not a club"; 
        } 
    }

尽管此处的enum未被作用域化(既不是enum class也不是enum struct),但不需要在case中指定值的作用域,但这样做会使常量所指的内容更加明显。

循环

大多数程序都需要循环执行一些代码。C++提供了几种方法来实现这一点,可以通过使用索引值进行迭代,也可以通过测试逻辑条件来实现。

迭代循环

for语句有两个版本,迭代和基于范围的。后者是在 C++11 中引入的。迭代版本的格式如下:

    for (init_expression; condition; loop_expression) 
        loop_statement;

您可以提供一个或多个循环语句,对于多个语句,应使用大括号提供代码块。循环的目的可能由循环表达式完成,在这种情况下,您可能不希望执行循环语句;在这种情况下,您可以使用空语句,表示什么也不做

括号内是由分号分隔的三个表达式。第一个表达式允许您声明和初始化循环变量。此变量的作用域限定为for语句,因此您只能在for表达式或随后的循环语句中使用它。如果您想要多个循环变量,可以使用逗号运算符在此表达式中声明它们。

for语句将在条件表达式为true时循环; 因此,如果您使用循环变量,可以使用此表达式来检查循环变量的值。第三个表达式在循环结束后调用; 随后调用循环语句,然后调用条件表达式以查看循环是否应继续。通常使用此最终表达式来更新循环变量的值。例如:

    for (int i = 0; i < 10; ++i)   
    { 
        std::cout << i; 
    }

在此代码中,循环变量是i,并且初始化为零。接下来,检查条件,由于i小于 10,将执行该语句(将值打印到控制台)。接下来是循环表达式; 调用++i,它会递增循环变量i,然后检查条件,依此类推。由于条件是i < 10,这意味着此循环将以i在 0 和 9 之间的值运行十次(因此您将在控制台上看到 0123456789)。

循环表达式可以是您喜欢的任何表达式,但通常会递增或递减值。您不必将循环变量值更改为 1;例如,您可以使用i -= 5作为循环表达式,以在每次循环时减少变量 5。循环变量可以是您喜欢的任何类型;它不必是整数,甚至不必是数字(例如,它可以是指针,或者是第八章中描述的迭代器对象使用标准库容器),条件和循环表达式也不必使用循环变量。实际上,您根本不必声明循环变量!

如果您不提供循环条件,那么循环将是无限的,除非您在循环中提供检查:

for (int i = 0; ; ++i)  
{ 
   std::cout << i << std::endl; 
   if (i == 10) break; 
}

这使用了早期引入的switch语句的break语句。它表示执行退出for循环,并且还可以使用returngotothrow。您很少会看到使用goto结束的语句;但是,您可能会看到以下内容:

for (;;)  
{ 
   // code 
}

在这种情况下,没有循环变量,没有循环表达式,也没有条件。这是一个永恒的循环,循环内的代码决定了循环何时结束。

for语句中的第三个表达式,循环表达式,可以是您喜欢的任何内容;唯一的属性是它在循环结束时执行。您可以选择在此表达式中更改另一个变量,或者甚至可以使用逗号运算符提供几个表达式。例如,如果您有两个函数,一个名为poll_data,如果有更多数据可用则返回true,当没有更多数据时返回false,以及一个名为get_data的函数,返回下一个可用的数据项,您可以使用for如下(请记住;这是一个虚构的例子,用于阐明观点):

for (int i = -1; poll_data(); i = get_data()) 
{ 
   if (i != -1) std::cout << i << std::endl; 
}

poll_data返回false值时,循环将结束。需要if语句,因为第一次调用循环时,尚未调用get_data。更好的版本如下:

for (; poll_data() ;) 
{ 
   int i = get_data();  
   std::cout << i << std::endl; 
}

记住这个例子,以备后续部分使用。

for循环中还有另一个关键字可以使用。在许多情况下,你的for循环会有很多行代码,而在某个时候,你可能会决定当前循环已经完成,你想开始下一个循环(或者更具体地说,执行循环表达式,然后测试条件)。为了做到这一点,你可以调用continue

for (float divisor = 0.f; divisor < 10.f; ++divisor)  
{ 
   std::cout << divisor; 
   if (divisor == 0)  
   {  
      std::cout << std::endl; 
      continue; 
   } 
   std::cout << " " << (1 / divisor) << std::endl; 
}

在这段代码中,我们打印了 0 到 9 的数的倒数(0.f是一个 4 字节的浮点文字)。for循环中的第一行打印循环变量,下一行检查变量是否为零。如果是,它会打印一个新行并继续,也就是说,for循环中的最后一行不会被执行。原因是最后一行打印了倒数,将任何数字除以零都会出错。

C11 引入了另一种使用for循环的方法,这种方法旨在与容器一起使用。C标准库包含容器类的模板。这些类包含对象的集合,并以标准方式提供对这些项目的访问。标准方式是使用迭代器对象遍历集合。如何做到这一点的更多细节将在第八章中给出,使用标准库容器;这种语法需要理解指针和迭代器,所以我们在这里不会涉及它们。基于范围的for循环提供了一种简单的机制来访问容器中的项目,而不需要显式使用迭代器。

语法很简单:

for (for_declaration : expression) loop_statement;

首先要指出的是只有两个表达式,它们之间用冒号(:)分隔。第一个表达式用于声明循环变量,它是正在迭代的集合中项目的类型。第二个表达式提供对集合的访问。

在 C++术语中,可以使用的集合是那些定义了beginend函数以访问迭代器的集合,以及基于堆栈的数组(编译器知道大小)。

标准库定义了一个叫做vector的容器对象。vector模板是一个包含在尖括号(<>)中指定类型的项目的类;在下面的代码中,vector以一种新的方式初始化,这是 C++11 中的新方法,称为列表初始化。这种语法允许你在花括号之间的列表中指定向量的初始值。以下代码创建和初始化了一个vector,然后使用迭代for循环打印出所有的值:

using namespace std; 
vector<string> beatles = { "John", "Paul", "George", "Ringo" }; 

for (int i = 0; i < beatles.size(); ++i)  
{ 
   cout << beatles.at(i) << endl; 
}

这里使用了using语句,这样vectorstring类就不必使用完全限定的名称。

vector类有一个成员函数叫做size(通过.操作符调用,意思是“在这个对象上调用这个函数”),它返回vector中项目的数量。每个项目都可以使用at函数通过传递项目的索引来访问。这段代码的一个大问题是它使用了随机访问,也就是说,它使用索引访问每个项目。这是vector的一个特性,但其他标准库容器类型没有随机访问。以下使用基于范围的for

vector<string> beatles = { "John", "Paul", "George", "Ringo" }; 

for (string musician : beatles)  
{ 
   cout << musician << endl; 
}

这个语法适用于任何标准容器类型和在堆栈上分配的数组:

int birth_years[] = { 1940, 1942, 1943, 1940 }; 

for (int birth_year : birth_years)  
{ 
   cout << birth_year << endl; 
}

在这种情况下,编译器知道数组的大小(因为编译器已经分配了数组),所以它可以确定范围。基于范围的for循环将遍历容器中的所有项目,但与之前的版本一样,你可以使用breakreturnthrowgoto离开for循环,并且你可以使用continue语句指示下一个循环应该执行。

条件循环

在前一节中,我们给出了一个牵强的例子,for循环中的条件轮询数据:

for (; poll_data() ;) 
{ 
   int i = get_data();  
   std::cout << i << std::endl; 
}

在这个例子中,在条件中没有使用循环变量。这是while条件循环的一个候选:

while (poll_data()) 
{ 
   int i = get_data();  
   std::cout << i << std::endl; 
}

该语句将继续循环,直到表达式(在本例中为poll_data)的值为false。与for一样,您可以使用breakreturnthrowgoto退出while循环,并且可以使用continue语句指示应执行下一个循环。

第一次调用while语句时,在执行循环之前会测试条件;在某些情况下,您可能希望至少执行一次循环,然后测试条件(很可能取决于循环中的操作),以查看是否应重复循环。这样做的方法是使用do-while循环:

int i = 5; 
do 
{ 
   std::cout << i-- << std::endl; 
} while (i > 0);

请注意while子句后面的分号。这是必需的。

这个循环将以逆序打印 1 到 5。原因是循环从i初始化为 5 开始。循环中的语句通过后缀运算符递减变量,这意味着在递减之前的值传递给流。循环结束时,while子句测试变量是否大于零。如果这个测试是true,则重复循环。当循环调用时,i赋值为 1,值 1 被打印到控制台并将变量递减为零,while子句将测试一个为false的表达式,循环将结束。

两种类型的循环之间的区别在于,在while循环中,在执行循环之前测试条件,因此可能不会执行循环。在do-while循环中,条件在循环之后调用,这意味着使用do-while循环时,循环语句始终至少被调用一次。

跳转

C++支持跳转,在大多数情况下,有更好的分支代码的方法;但是,为了完整起见,我们将在这里介绍机制。跳转有两个部分:要跳转到的标记语句和goto语句。标签具有与变量相同的命名规则;它以冒号结尾声明,并且必须在语句之前。使用标签的goto语句如下所示:

    int main() 
    { 
        for (int i = 0; i < 10; ++i) 
        { 
            std::cout << i << std::endl; 
            if (i == 5) goto end; 
        } 

    end:
        std::cout << "end"; 
    }

标签必须与调用goto的同一函数中。

跳转很少使用,因为它鼓励您编写非结构化的代码。但是,如果您有高度嵌套的循环或if语句的例程,使用goto跳转到清理代码可能更有意义且更易读。

使用 C++语言特性

现在让我们使用本章学到的特性来编写一个应用程序。这个例子是一个简单的命令行计算器;您可以输入一个表达式,比如6 * 7,应用程序会解析输入并进行计算。

启动 Visual C++,单击“文件”菜单,然后单击“新建”,最后单击“文件...”选项以获取新文件对话框。在左侧窗格中,单击 Visual C++,在中间窗格中,单击 C文件(.cpp),然后单击“打开”按钮。在做任何其他操作之前,请保存此文件。使用 Visual C控制台(Visual C环境中的命令行),导航到您在上一章中创建的Beginning_C++文件夹,并创建一个名为Chapter_02的新文件夹。现在,在 Visual C中,单击“文件”菜单,单击“另存为...”,在“另存为”对话框中找到刚刚创建的Chapter_02文件夹。在“文件名”框中,键入 calc.cpp,然后单击“保存”按钮。

应用程序将使用std::coutstd::string;因此,在文件顶部添加定义这些的头文件,并且为了不必使用完全限定的名称,添加一个using语句:

    #include <iostream> 
    #include <string> 

    using namespace std;

您将通过命令行传递表达式,因此在文件底部添加一个接受命令行参数的main函数:

    int main(int argc, char *argv[]) 
    { 
    }

应用程序处理形式为arg1 op arg2的表达式,其中op是运算符,arg1arg2是参数。这意味着,当调用应用程序时,必须有四个参数;第一个是用于启动应用程序的命令,最后三个是表达式。main函数中的第一行代码应该确保提供了正确数量的参数,因此在这个函数的顶部添加一个条件,如下所示:

    if (argc != 4) 
    { 
        usage(); 
        return 1; 
    }

如果命令被调用时参数多于或少于四个,会调用usage函数,然后main函数返回,停止应用程序。

main函数之前添加usage函数,如下所示:

    void usage() 
    { 
        cout << endl; 
        cout << "calc arg1 op arg2" << endl; 
        cout << "arg1 and arg2 are the arguments" << endl; 
        cout << "op is an operator, one of + - / or *" << endl; 
    }

这只是简单地解释了如何使用命令并解释了参数。在这一点上,您可以编译应用程序。由于您使用了 C标准库,您需要编译支持 C异常,因此在命令行中输入以下内容:

C:\Beginning_C++Chapter_02\cl /EHsc calc.cpp

如果您输入的代码没有任何错误,文件应该可以编译。如果您从编译器那里得到任何错误,请检查源文件,看看代码是否与前面的代码完全一样。您可能会得到以下错误:

'cl' is not recognized as an internal or external command,  
operable program or batch file.

这意味着控制台没有设置为 Visual C++环境,因此要么关闭它并通过 Windows 开始菜单启动控制台,要么运行 vcvarsall.bat 批处理文件。如何执行这两个步骤在前一章中已经给出。

一旦代码编译完成,您可以运行它。首先用正确数量的参数运行它(例如calc 6 * 7),然后尝试用不正确数量的参数运行它(例如calc 6 * 7 / 3)。请注意参数之间的空格很重要:

C:\Beginning_C++Chapter_02>calc 6 * 7 

C:\Beginning_C++Chapter_02>calc 6 * 7 / 3 

calc arg1 op arg2 
arg1 and arg2 are the arguments 
op is an operator, one of + - / or *

在第一种情况下,应用程序什么也不做,所以您只会看到一个空行。在第二个例子中,代码已经确定参数不足,因此它会将用法信息打印到控制台。

接下来,您需要对参数进行一些简单的解析,以检查用户是否传递了有效值。在main函数的底部,添加以下内容:

    string opArg = argv[2]; 
    if (opArg.length() > 1) 
    { 
        cout << endl << "operator should be a single character" << endl; 
        usage(); 
        return 1; 
    }

第一行使用第三个命令行参数初始化了一个 C++ std::string对象,这应该是表达式中的运算符。这个简单的例子只允许运算符是单个字符,所以下面的行检查以确保运算符是单个字符。C++ std::string类有一个名为length的成员函数,返回字符串中的字符数。

argv[2]参数的长度至少为一个字符(长度为零的参数将不被视为命令行参数!),因此我们必须检查用户是否输入了一个超过一个字符的运算符。

接下来,您需要测试以确保参数是允许的受限集之一,如果用户输入了另一个运算符,则打印错误并停止处理。在main函数的底部,添加以下内容:

    char op = opArg.at(0); 
    if (op == 44 || op == 46 || op < 42 || op > 47) 
    { 
        cout << endl << "operator not recognized" << endl; 
        usage(); 
        return 1; 
    }

测试将在一个字符上进行,因此您需要从string对象中提取这个字符。这段代码使用at函数,传递了您需要的字符的索引。(第八章,使用标准库容器,将更详细地介绍std::string类的成员。)下一行检查字符是否不受支持。代码依赖于我们支持的字符的以下值:

字符
+ 42
* 43
- 45
/ 47

如您所见,如果字符小于42或大于47,它将是不正确的,但在4247之间还有两个我们想要拒绝的字符:,44)和.46)。这就是为什么我们有前面的条件:“如果字符小于 42 或大于47,或者是4446,那么拒绝它。”

char数据类型是一个整数,这就是为什么测试使用整数文字的原因。您可以使用字符文字,所以下面的更改同样有效:

 if (op == ',' || op == '.' || op < '+' || op > '/') 
    { 
        cout << endl << "operator not recognized" << endl; 
        usage(); 
        return 1; 
    }

您应该使用您认为最可读的那个。因为检查一个字符是否大于另一个字符更没有意义,本书将使用前者。

此时,您可以编译代码并进行测试。首先尝试使用一个多于一个字符的运算符(例如**),并确认您收到了运算符应该是单个字符的消息。其次,尝试使用一个不被识别的运算符;尝试任何不是+*-/的字符,但也值得尝试.,

请记住,命令提示符对一些符号有特殊操作,比如“&”和“|”,命令提示符可能会在调用代码之前解析命令行而给您带来错误。

接下来要做的是将参数转换为代码可以使用的形式。命令行参数以字符串数组的形式传递给程序;然而,我们将一些参数解释为浮点数(实际上是双精度浮点数)。C 运行时提供了一个名为atof的函数,它可以通过 C++标准库(在本例中,<iostream>包含了包含<cmath>的文件,其中声明了atof)。

通过包含与流输入和输出相关的文件来访问atof这样的数学函数有点反直觉。如果这让你感到不安,你可以在include行后添加一行来包含<cmath>文件。正如前一章所述,C++标准库头文件已经被编写,以确保头文件只被包含一次,因此两次包含<cmath>没有任何不良影响。这在前面的代码中没有做,因为有人认为atof是一个字符串函数,代码包含了<string>头文件,而且确实,<cmath>是通过<string>头文件包含的。

main函数的底部添加以下行。前两行将第二个和第四个参数(记住,C++数组是从零开始索引的)转换为double值。最后一行声明一个变量来保存结果:

    double arg1 = atof(argv[1]); 
    double arg2 = atof(argv[3]); 
    double result = 0;

现在我们需要确定传递了哪个运算符并执行请求的操作。我们将使用switch语句来做这个。我们知道op变量将是有效的,因此我们不必提供一个default子句来捕获我们没有测试的值。在函数的底部添加一个switch语句:

    double arg1 = atof(argv[1]); 
    double arg2 = atof(argv[3]); 
    double result = 0; 

    switch(op) 
    { 
    }

前三个案例,+-*,都很简单:

    switch (op) 
    { 
 case '+': result = arg1 + arg2; break; case '-': result = arg1 - arg2; break; case '*': result = arg1 * arg2; break; 
    }

再次,由于char是一个整数,您可以在switch语句中使用它,但 C++允许您检查字符值。在这种情况下,使用字符而不是数字使得代码更易读。

switch之后,添加最终代码以打印结果:

    cout << endl; 
    cout << arg1 << " " << op << " " << arg2; 
    cout << " = " << result << endl;

现在您可以编译代码并测试涉及+-*的计算。

除法是一个问题,因为被零除是无效的。要测试这个,添加以下行到switch的底部:

 case '/': result = arg1 / arg2; break;

编译并运行代码,将零作为最后一个参数传递:

C:\Beginning_C++Chapter_02>calc 1 / 0 
1 / 0 = inf

代码成功运行,并打印出表达式,但它说结果是一个奇怪的inf值。这里发生了什么?

被零除将result赋值为NAN,这是在<math.h>(通过<cmath>包含)中定义的一个常量,意思是“不是一个数字”。cout对象的double重载插入运算符测试看数字是否有有效值,如果数字的值是NAN,它打印字符串 inf。在我们的应用程序中,我们可以测试零除数,并将用户传递零的操作视为错误。因此,更改代码如下:

    case '/': 
 if (arg2 == 0) { cout << endl << "divide by zero!" << endl; return 1; } else { 
        result = arg1 / arg2; 
 } 
    break;

现在当用户将零作为除数传递时,您将得到一个divide by zero!的消息。

您现在可以编译完整的示例并进行测试。该应用程序支持使用+-*/运算符进行浮点运算,并将处理除以零的情况。

总结

在本章中,您已经学会了如何格式化您的代码,以及如何识别表达式和语句。您已经学会了如何识别变量的作用域,以及如何将函数和变量的集合分组到命名空间中,以防止名称冲突。您还学会了 C++中循环和分支代码的基本原理,以及内置运算符的工作原理。最后,您将所有这些内容整合到一个简单的应用程序中,该应用程序允许您在命令行上执行简单的计算。

在接下来的章节中,您将学习关于 C++类型以及如何将一个类型的值转换为另一个类型。

第三章:探索 C++类型

在过去的两章中,您已经学会了如何组合 C++程序,了解了您使用的文件以及控制执行流程的方法。本章是关于您将在程序中使用的数据:数据类型和将保存该数据的变量。

变量可以处理特定格式和特定行为的数据,这由变量的类型确定。变量的类型确定您可以对数据执行的操作以及用户输入或查看数据的格式。

基本上,您可以查看三种一般类型:内置类型、自定义类型和指针。指针通常将在下一章中介绍,自定义类型或类以及指向它们的指针将在第六章《类》中介绍。本章将介绍作为 C++语言一部分提供的类型。

探索内置类型

C提供整数、浮点和布尔类型。char类型是整数,但它可以用于保存单个字符,因此其数据可以被视为数字或字符。C标准库提供了string类,允许您使用和操作字符串。字符串将在第九章《使用字符串》中深入介绍。

顾名思义,整数类型包含没有小数部分的整数值。如果使用整数进行计算,您应该期望任何小数部分都将被丢弃,除非您采取措施保留它们(例如,通过取余运算符%)。浮点类型保存可能具有小数部分的数字;因为浮点类型可以以尾数指数格式保存数字,所以它们可以保存异常大或异常小的数字。

变量是类型的实例;它是分配的内存,用于保存类型可以保存的数据。整数和浮点变量声明可以修改以告诉编译器分配多少内存,从而限制变量可以保存的数据和对变量执行的计算的精度。此外,您还可以指示变量是否将保存重要的符号数字。如果数字用于保存位图(其中位不组成数字,而具有自己的独立含义),则通常没有意义使用有符号类型。

在某些情况下,您将使用 C++从文件或网络流中解压数据,以便对其进行操作。在这种情况下,您需要知道数据是浮点还是整数,有符号还是无符号,使用了多少字节以及这些字节的顺序。字节的顺序(多字节数字中的第一个字节是数字的低位还是高位)由您正在编译的处理器确定,在大多数情况下,您不需要担心它。

同样,有时您可能需要了解变量的大小以及它在内存中的对齐方式;特别是当您使用 C中称为structs的数据记录时。C提供了sizeof运算符来给出用于保存变量的字节数,以及alignof运算符来确定内存中类型的对齐方式。对于基本类型,sizeofalignof运算符返回相同的值;只有在自定义类型上调用alignof运算符时,它才会返回类型中最大数据成员的对齐方式。

整数

顾名思义,整数保存整数数据,即没有小数部分的数字。因此,在需要重视小数部分的情况下,使用整数进行任何算术运算几乎没有意义;在这种情况下,应该使用浮点数。上一章中展示了一个例子:

    int height = 480;  
    int width = 640; 
    int aspect_ratio = width / height;

这给出了一个明显不正确且毫无意义的宽高比。即使将结果分配给浮点数,您也会得到相同的结果:

    float aspect_ratio = width / height;

原因是表达式width / height中的算术是在整数上执行的,这将使用整数的除法运算符丢弃结果的任何小数部分。要使用浮点除法运算符,您将需要将操作数之一强制转换为浮点数,以便使用浮点运算符:

    float aspect_ratio = width / (float)height;

这将为aspect_ratio变量分配一个值为 1.3333(或 4:3)。这里使用的强制转换运算符是 C 强制转换运算符,它强制将一个类型的数据用作另一个类型的数据。(这是因为我们还没有介绍 C强制转换运算符,并且 C 强制转换运算符的语法是清晰的。)这种转换没有类型安全性。C提供了强制转换运算符,下文将讨论其中一些将以类型安全的方式进行转换,当您使用自定义类型的对象指针时,这将变得很重要。

C++提供了各种大小的整数类型,如下表所总结。这些是五种标准整数类型。标准规定int是处理器的自然大小,并且其值在(包括)INT_MININT_MAX之间(在<climits>头文件中定义)。整数类型的大小至少与列表中前面的整数类型一样大,因此int至少与short intlong long int类型一样大,至少与long int类型一样大。短语“至少与”如果这些类型都是相同大小,那么就没有多大用处,因此<climits>头文件还为其他基本整数类型定义了范围。存储这些整数范围需要多少字节是依赖于实现的。这个表给出了基本类型的范围和 x86,32 位处理器上的大小:

类型 范围 字节大小
signed char -128 到 127 1
short int -32768 到 32767 2
int -2147483648 到 2147483647 4
long int -2147483648 到 2147483647 4
long long int -9223372036854775808 到 9223372036854775807 8

在实践中,您将使用short而不是short int类型;对于long int,您将使用long;对于long long int,通常会使用long long。从这个表中可以看出,intlong int类型的大小相同,但它们仍然是两种不同的类型。

除了char类型,缺省情况下整数类型都是有符号的,也就是说,它们可以保存负数和正数(例如,short类型的变量的值可以在-32,768 和 32,767 之间)。您可以使用signed关键字显式指示类型为有符号。您还可以使用unsigned关键字来获得无符号的等价类型,这将给您一个额外的位,但也意味着按位运算符和移位运算符将按您的预期工作。您可能会发现unsigned在没有类型的情况下使用,这种情况下它指的是unsigned int。类似地,没有类型的signed指的是signed int

char类型是unsigned charsigned char的独立类型。标准规定char中的每一位都用于保存字符信息,因此根据实现的不同,char是否可以被视为能够保存负数是依赖于实现的。如果您希望char保存有符号数,您应该明确使用signed char

标准对于标准整数类型的大小并不精确,如果您正在编写代码(例如,访问文件中的数据或网络流),这可能是一个问题。<cstdlib>头文件定义了将保存特定数据范围的命名类型。这些类型具有包含在范围内使用的位数的名称(尽管实际类型可能需要更多位)。因此,有诸如int16_tuint16_t之类的类型,其中第一个类型是将保存 16 位值范围的有符号整数,第二个类型是无符号整数。还声明了 8 位、32 位和 64 位值的类型。

以下显示了在 x86 机器上使用sizeof运算符确定的这些类型的实际大小:

    // #include <cstdint> 
    using namespace std;               // Values for x86 
    cout << sizeof(int8_t)  << endl;   // 1 
    cout << sizeof(int16_t) << endl;   // 2 
    cout << sizeof(int32_t) << endl;   // 4 
    cout << sizeof(int64_t) << endl;   // 8

此外,<cstdlib>头文件还定义了诸如int_least16_tuint_least16_t之类的类型,使用与之前相同的命名方案,并且有 8 位、16 位、32 位和 64 位的版本。名称中的least部分表示该类型将保存至少指定数量的位的值,但可能会更多。还有诸如int_fast16_tuint_fast16_t之类的类型,具有 8 位、16 位、32 位和 64 位的版本,被视为可以保存该位数的最快类型。

指定整数文字

要为整数变量赋值,您提供一个没有小数部分的数字。编译器将确定数字表示的最接近精度的类型,并尝试分配整数,必要时执行转换。

要明确指定文字是long值,您可以使用lL后缀。同样,对于unsigned long,您可以使用后缀ulUL。对于long long值,您使用llLL后缀,并对于unsigned long long使用ullULLu(或U)后缀用于unsigned(即unsigned int),对于int不需要后缀。以下是使用大写后缀的示例:

    int i = 3; 
    signed s = 3; 
    unsigned int ui = 3U; 
    long l = 3L; 
    unsigned long ul = 3UL; 
    long long ll = 3LL; 
    unsigned long long ull = 3ULL;

使用 10 进制数字系统来指定位图的数字是令人困惑和繁琐的。位图中的位是 2 的幂,因此更合理的是使用 2 的幂的数字系统。C++允许您以八进制(基数 8)或十六进制(基数 16)提供数字。要在八进制中提供文字,您需要使用零字符(0)作为前缀。要在十六进制中提供文字,您需要使用0x字符序列作为前缀。八进制数字使用数字 0 到 7,但十六进制数字需要 16 个数字,即 0 到 9 和 a 到 f(或 A 到 F),其中 A 在十进制中是 10,F 在十进制中是 15:

    unsigned long long every_other = 0xAAAAAAAAAAAAAAAA; 
    unsigned long long each_other  = 0x5555555555555555; 
    cout << hex << showbase << uppercase; 
    cout << every_other << endl; 
    cout << each_other  << endl;

在此代码中,两个 64 位(在 Visual C++中)整数被分配了位图值,其中每隔一位设置为 1。第一个变量从底位设置,第二个变量从底位取消设置,并设置次低位。在插入数字之前,流被修改了三个操纵器。第一个hex表示整数应以十六进制形式打印在控制台上,showbase表示将打印前导的0x。默认情况下,字母数字(A 到 F)将以小写形式给出,要指定必须使用大写形式,您可以使用uppercase。一旦流被修改,设置将保持直到被更改。要随后更改流以使用小写字母十六进制数字,您可以在流中插入nouppercase,要打印没有基数的数字,插入noshowbase操纵器。要使用八进制数字,您可以插入oct操纵器,要使用十进制,插入dec操纵器。

当您指定这样的大数字时,很难看出您是否已经指定了正确数量的数字。您可以使用单引号(')将数字分组在一起:

    unsigned long long every_other = 0xAAAA'AAAA'AAAA'AAAA; 
    int billion = 1'000'000'000;

编译器忽略引号;它只是用作视觉辅助。在第一个示例中,引号将数字分组为两个字节组;在第二种情况下,引号将小数分组为千位和百万位。

使用位集来显示位模式

没有操纵器告诉cout对象将整数打印为位图,但是可以使用bitset对象模拟该行为:

    // #include <bitset> 
    unsigned long long every_other = 0xAAAAAAAAAAAAAAAA; 
    unsigned long long each_other  = 0x5555555555555555; 
    bitset<64> bs_every(every_other); 
    bitset<64> bs_each(each_other); 
    cout << bs_every << endl; 
    cout << bs_each << endl;

结果是:

    1010101010101010101010101010101010101010101010101010101010101010    
    0101010101010101010101010101010101010101010101010101010101010101

在这里,bitset类是参数化的,这意味着您通过尖括号(<>)提供一个参数,在这种情况下使用 64,表示bitset对象将容纳 64 位。在这两种情况下,bitset对象的初始化使用看起来像函数调用的语法(实际上,它确实调用了一个称为构造函数的函数),这是初始化对象的首选方式。将bitset对象插入流中,打印出从最高位开始的每个位。(原因是定义了一个operator <<函数,它接受一个bitset对象,这是大多数标准库类的情况)。

bitset类对于访问和设置单个位而不使用位运算符是有用的:

    bs_every.set(0); 
    every_other = bs_every.to_ullong(); 
    cout << bs_every << endl; 
    cout << every_other << endl;

set函数将在指定位置设置位为 1。to_ullong函数将返回bitset表示的long long数字。

set函数的调用和赋值具有与以下相同的结果:

    every_other |= 0x0000000000000001;

确定整数字节顺序

整数中字节的顺序取决于实现;它取决于处理器如何处理整数。在大多数情况下,您不需要知道。但是,如果您以二进制模式从文件中读取字节,或者从网络流中读取字节,并且需要将两个或更多字节解释为整数的一部分,则需要知道它们的顺序,并且必要时将它们转换为处理器识别的顺序。

C 网络库(在 Windows 上称为Winsock库)包含一组函数,用于将unsigned shortunsigned long类型从网络顺序转换为主机顺序(即当前机器上处理器使用的顺序),反之亦然。网络顺序是大端序。大端序意味着第一个字节将是整数中的最高字节,而小端序意味着第一个字节是最小字节。当您将整数传输到另一台机器时,您首先将其从源机器的处理器使用的顺序(主机顺序)转换为网络顺序,接收机在使用数据之前将整数从网络顺序转换为接收机的主机顺序。

更改字节顺序的函数是ntohsntohl;用于将unsigned shortunsigned long从网络顺序转换为主机顺序的函数,以及htonshtonl,用于将主机顺序转换为网络顺序。在调试代码时,了解字节顺序将是重要的(例如,如第十章中所述,诊断和调试)。

编写代码以反转字节顺序很容易:

    unsigned short reverse(unsigned short us)  
    { 
        return ((us & 0xff) << 8) | ((us & 0xff00) >> 8); 
    }

这使用位运算符将假定组成unsigned short的两个字节分开为较低字节,将其左移八位,并将右移八位的上字节,然后使用按位或运算符|将这两个数字重新组合为unsigned short。编写此函数的 4 字节和 8 字节整数版本很简单。

浮点类型

有三种基本的浮点类型:

  • float(单精度)

  • double(双精度)

  • long double(扩展精度)

所有这些都是有符号的。内存中数字的实际格式和使用的字节数是特定于 C++实现的,但<cfloat>头文件给出了范围。以下表格给出了 x86、32 位处理器上使用的正数范围和字节数:

类型 范围 字节大小
浮点 1.175494351e-38 到 3.402823466e+38 4
双精度 2.2250738585072014e-308 到 1.7976931348623158e+308 8
长双精度 2.2250738585072014e-308 到 1.7976931348623158e+308 8

正如您所看到的,在 Visual C++中,doublelong double具有相同的范围,但它们仍然是两种不同的类型。

指定浮点文字

用于初始化double的文字是通过使用科学格式或简单地提供小数点来指定的浮点数:

    double one = 1.0; 
    double two = 2.; 
    double one_million = 1e6;

第一个例子表明变量one被赋予了浮点值 1.0。结尾的零并不重要,如第二个变量two所示;然而,结尾的零确实使代码更易读,因为很容易忽略句号。第三个例子使用了科学计数法。第一部分是尾数,可以是有符号的,e后面的部分是指数。指数是数字的 10 的幂大小(可以是负数)。变量被赋予尾数乘以 10 并提升到指数的值。虽然不建议这样做,但您可以写以下内容:

    double one = 0.0001e4; 
    double one_billion = 1000e6;

编译器将适当地解释这些数字。第一个例子是反常的,但第二个有些意义;它在您的代码中显示了十亿是一千万的意思。

这些示例将双精度浮点值分配给double变量。要为单精度变量指定值,以便可以分配float变量,使用f(或F)后缀。类似地,对于long double文字,使用l(或L)后缀:

    float one = 1.f; 
    float two = 2f; // error 
    long double one_million = 1e6L;

如果您使用这些后缀,仍然必须以正确的格式提供数字。2f的文字是不正确的;您必须提供一个小数点,2.f。当您指定具有大量数字的浮点数时,可以使用单引号(')来分组数字。如前所述,这只是对程序员的一种视觉辅助:

    double one_billion = 1'000'000'000.;

字符和字符串

string类和 C 字符串函数将在第九章中介绍,使用字符串;本节介绍了代码中字符变量的基本用法。

字符类型

char类型是一个整数,所以也存在signed charunsigned char。这是三种不同的类型;signed charunsigned char类型应该被视为数值类型。char类型用于在实现的字符集中保存单个字符。在 Visual C中,这是一个可以容纳 ISO-8859 或 UTF-8 字符集中的字符的 8 位整数。这些字符集能够表示英语和大多数欧洲语言中使用的字符。其他语言的字符占用多个字节,C提供了char16_t类型来保存 16 位字符和char32_t来保存 32 位字符。

还有一种称为wchar_t(宽字符)的类型,它将能够容纳来自最大扩展字符集的字符。通常,当您看到带有w前缀的 C 运行时库或 C++标准库函数时,它将使用宽字符字符串而不是char字符串。因此,cout对象将允许您插入char字符串,而wcout对象将允许您插入宽字符字符串。

C++标准规定char中的每个位都用于保存字符信息,因此根据实现,char是否可以被视为能够保存负数是依赖于实现的。以下是说明:

    char c = '~'; 
    cout << c << " " << (signed short)c << endl; 
    c += 2; 
    cout << c << " " << (signed short)c << endl;

signed char的范围是-128 到 127,但此代码使用了单独的类型char并尝试以相同的方式使用它。变量c首先被赋值为 ASCII 字符~(126)。当您将字符插入输出流时,它将尝试打印一个字符而不是一个数字,因此下一行将此字符打印到控制台,为了获得数值,代码将变量转换为signed short整数。(再次,为了清晰起见,使用了 C 转换。)接下来,变量增加了两个,也就是说,字符在字符集中向后移动了两个字符,这意味着扩展 ASCII 字符集中的第一个字符;结果是这样的:

    ~ 126
    C -128

扩展字符集中的第一个字符是 C-锐音。

值为 126 增加两个的结果是-128,这相当反直觉,并且这是由于带符号类型的溢出计算导致的。即使这是有意的,最好还是避免这样做。

在 Visual C++中,C-锐音字符被视为-128,因此您可以编写以下内容以达到相同的效果:

    char c = -128;

这是特定于实现的,因此对于可移植代码,您不应该依赖它。

使用字符宏

<cctype>头文件包含了各种宏,您可以使用这些宏来检查char包含的字符类型。这些是在<ctype.h>中声明的 C 运行时宏。以下表格中解释了一些更有用的测试字符值的宏。请记住,由于这些是 C 例程,它们不会返回bool值;而是返回一个具有非零值的int表示true,零表示false

测试字符是否为:
isalnum 字母数字字符,A 到 Z,a 到 z,0 到 9
isalpha 一个字母字符,A 到 Z,a 到 z
isascii 一个 ASCII 字符,0x00 到 0x7f
isblank 一个空格或水平制表符
iscntrl 一个控制字符,0x00 到 0x1f 或 0x7f
isdigit 十进制数字 0 到 9
isgraph 可打印字符,不包括空格,0x21 到 0x7e
islower 一个小写字符,a 到 z
isprint 可打印字符,0x20 到 0x7e
ispunct 一个标点字符,! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ ] ^ _ { | } ~ `
isspace 一个空格
isupper 一个大写字符,A 到 Z
isxdigit 一个十六进制数字,0 到 9,a 到 f,A 到 F

例如,以下代码循环读取输入流中的单个字符(在每个字符后,您需要按Enter键)。当提供非数字值时,循环结束:

    char c; 
    do 
    { 
       cin >> c 
    } while(isdigit(c));

还有用于更改字符的宏。同样,这些将返回一个int值,您应该将其转换为char

返回
toupper 字符的大写版本
tolower 字符的小写版本

在以下代码中,从控制台键入的字符被回显,直到用户键入qQ为止。如果键入的字符是小写字符,则回显的字符会转换为大写:

    char c; 
    do 
    { 
        cin >> c; 
        if (islower(c)) c = toupper(c); 
        cout << c << endl; 
    } while (c != 'Q');

指定字符文字

您可以使用文字字符初始化char变量。这将是受支持的字符集中的一个字符。ASCII 字符集包括一些不可打印的字符,因此您可以使用这些,C++提供了两个使用反斜杠字符(\)的字符序列。

名称 ASCII 名称 C++序列
换行符 LF \n
水平制表符 HT \t
垂直制表符 VT \v
退格 BS \b
回车 CR \r
换页符 FF \f
警报 BEL \a
反斜杠 \ \\
问号 ? \?
单引号 ' \'
双引号 " \"

此外,您还可以将该字符的数值作为八进制或十六进制数给出。要提供八进制数,您需要使用三个字符(必要时前缀为一个或两个0字符)前缀为反斜杠。对于十六进制数,您需要使用\x前缀。字符M在十进制中是字符编号 77,在八进制中是 115,在十六进制中是 4d,因此您可以用三种方式初始化一个字符变量为M字符。

    char m1 = 'M'; 
    char m2 = '\115'; 
    char m3 = '\x4d';

为了完整起见,值得指出您可以将 char 初始化为整数,因此以下内容也将初始化每个变量为M字符:

    char m4 = 0115; // octal 
    char m5 = 0x4d; // hexadecimal

所有这些方法都是有效的。

指定字符串文字

字符串由一个或多个字符组成,您也可以在字符串文字中使用转义字符。

    cout << "This is \x43\x2b\05\3n";

这个相当难读的字符串将被打印在控制台上,后面跟着一个换行符,显示为This is C++。大写字母 C 的十六进制是 43,加号的十六进制是 2b,八进制是 53。\n字符是一个换行符。转义字符对于打印不在 C++编译器使用的字符集中的字符以及一些不可打印的字符(例如,\t插入水平制表符)非常有用。cout对象在将字符写入输出流之前会对其进行缓冲。如果您使用\n作为换行符,它将被视为缓冲区中的任何其他字符。endl操作符将\n插入缓冲区,然后刷新缓冲区,使字符立即写入控制台。

NULL字符是\0。这是一个重要的字符,因为它是不可打印的,并且除了标记字符串中字符序列的结束之外没有其他用途。空字符串是"",但由于字符串由NULL字符界定,因此使用空字符串初始化的字符串变量占用的内存将有一个字符,即\0

换行符允许您在字符串中插入换行符。如果您只对段落进行格式化,并且要打印短段落,这将非常有用。

    cout << "Mary had a little lamb,n its fleece was white as snow."  
         << endl;

这在控制台上打印了两行:

 Mary had a little lamb,
 its fleece was white as snow.

但是,您可能希望使用长序列的字符初始化字符串,而您使用的编辑器的限制可能意味着您希望将字符串分割成几行。您可以通过将字符串的每个片段放在双引号内来实现这一点。

    cout << "And everywhere that Mary went, " 
            "the lamb was sure to go."  
         << endl;

您将在控制台上看到以下内容:

 And everywhere that Mary went, the lamb was sure to go.

除了在最后使用endl明确请求的换行符外,不会打印其他换行符。这种语法允许您在代码中使长字符串更易读;当然,您也可以在这样的字符串中使用换行字符\n

Unicode 文字

wchar_t变量也可以用字符初始化,编译器将通过使用字符的字节并将剩余(更高的)字节分配为零来将字符提升为宽字符。但是,将这样的变量分配为宽字符更有意义,您可以使用L前缀来实现这一点。

    wchar_t dollar = L'$'; 
    wchar_t euro = L'\u20a0'; 
    wcout << dollar;

请注意,这段代码使用的是wcout,而不是cout对象,使用引号内的\u前缀的语法表示后面的字符是 Unicode 字符。

请注意,要显示 Unicode 字符,您需要使用一个可以显示 Unicode 字符的控制台,默认情况下,Windows 控制台设置为Code Page 850,不会显示 Unicode 字符。您可以通过在标准输出流stdout上调用_setmode(在<io.h>中定义)来更改输出控制台的模式,指定 UTF-16 文件模式(使用<fcntl.h>中定义的_O_U16TEXT):

    _setmode(_fileno(stdout), _O_U16TEXT);

您可以在unicode.org/charts/找到 Unicode 支持的所有字符的列表。

UTF-16 字符也可以分配给char16_t变量,UTF-32 字符也可以分配给char32_t变量。

原始字符串

当您使用原始字符串文字时,实质上是关闭了转义字符的含义。无论您输入什么内容到原始字符串中,甚至包括换行符在内,原始字符串都会将其作为内容。原始字符串用R"()"来界定。也就是说,字符串位于内部括号之间。

    cout << R"(newline is \n in C++ and "quoted text" use quotes)";

请注意,()是语法的一部分,不是字符串的一部分。前面的代码将以下内容打印到控制台:

 newline is \n in C++ and "quoted text" use quotes

通常在字符串中,\n是一个转义字符,将被翻译为换行符,但在原始字符串中,它不会被翻译,而是打印为两个字符。

在普通的 C++字符串中,您将不得不转义一些字符;例如,双引号必须转义为\",反斜杠必须转义为\\。不使用原始字符串,以下将给出相同的结果:

    cout << "newline is \\n in C++ and \"quoted text\" use quotes";

您还可以在原始字符串中使用换行符:

    cout << R"(Mary had a little lamb,  
                             its fleece was white as snow)" 
    cout << endl;

在这段代码中,逗号后面的换行符将被打印到控制台。不幸的是,所有空白字符都将被打印到控制台上,因此假设在前面的代码中缩进为三个空格,cout缩进一次,您将在控制台上看到以下内容:

 Mary had a little lamb,
 its fleece was white as snow

its前面有 14 个空格,因为在源代码中its前面有 14 个空格。因此,您应该谨慎使用原始字符串。

也许,原始字符串的最佳用途是在 Windows 上初始化文件路径的变量。在 Windows 中,文件夹分隔符是反斜杠,这意味着对于表示文件路径的文字字符串,您将不得不转义每个这些分隔符;因此,字符串将有很多双反斜杠,有可能漏掉一个。使用原始字符串,这种转义是不必要的。以下的两个字符串变量代表相同的字符串:

    string path1 = "C:\\Beginning_C++\\Chapter_03\\readme.txt"; 
    string path2 = R"(C:\Beginning_C++\Chapter_03\readme.txt)";

这两个字符串具有相同的内容,但第二个更易读,因为 C++文字字符串没有转义反斜杠。

转义反斜杠的要求仅适用于在代码中声明的文字字符串;这是对编译器如何解释字符的指示。如果您从函数(或通过argv[0])获取文件路径,分隔符将是反斜杠。

字符串字节顺序

扩展字符集使用每个字符超过一个字节。如果这些字符存储在文件中,字节的顺序就变得重要起来。在这种情况下,字符的编写者必须使用与潜在读者将要使用的相同顺序。

一种方法是使用字节顺序标记BOM)。这是已知字节数和已知模式的一组字节,通常作为流的第一项放置,以便流的读取者可以使用它来确定流中剩余字符的字节顺序。Unicode 定义了 16 位字符\uFEFF和非字符\uFFFE作为字节顺序标记。对于\uFEFF,除了第 8 位(如果最低位标记为第 0 位)之外,所有位都被设置。这个 BOM 可以作为前缀添加到在机器之间传递的数据中。目标机器可以将 BOM 读入一个 16 位变量并测试位。如果第 8 位为零,这意味着两台机器具有相同的字节顺序,因此字符可以按照流中的顺序读取为两个字节值。如果第 0 位为零,则意味着目标机器以与源机器相反的顺序读取 16 位变量,因此必须采取措施确保以正确的顺序读取字节。

Unicode 字节顺序标记(BOM)序列化如下(十六进制):

字符集 字节顺序标记
UTF-8 EF BB BF
UTF-16 大尾 FE FF
UTF-16 小尾 FF FE
UTF-32 大尾 00 00 FE FF
UTF-32 小尾 FF FE 00 00

请记住,当您从文件中读取数据时。字符序列 FE FF 在非 Unicode 文件中非常罕见,因此如果您将它们读取为文件中的前两个字节,这意味着该文件是 Unicode。由于\uFEFF\uFFFE不是可打印的 Unicode 字符,这意味着以这两者之一开头的文件具有字节顺序标记,然后您可以使用 BOM 来确定如何解释文件中剩余的字节。

布尔

bool类型保存布尔值,即两个值中的一个:truefalse。C++允许您将 0(零)视为false,将任何非零值视为true,但这可能会导致错误,因此最好养成明确检查值的习惯:

    int use_pointer(int *p) 
    { 
        if (p)            { /* not a null pointer */ } 
        if (p != nullptr) { /* not a null pointer */ }   
        return 0; 
    }

这两种方式中的第二种更可取,因为您正在比较的内容更清晰。

请注意,即使指针不是nullptr,它仍然可能不是有效的指针,但通常的做法是将指针分配给nullptr以传达其他含义,也许是说指针操作不合适。

可以将布尔值插入输出流。但是,默认行为是将布尔值视为整数。如果要使cout输出带有字符串名称的bool值,则在流中插入操作符boolalpha;这将使流打印truefalse到控制台。可以使用noboolalpha操作符来实现默认行为。

void

在某些情况下,您需要指示函数没有参数或不会返回值;在这两种情况下,您可以使用关键字void

    void print_message(void) 
    { 
        cout << "no inputs, no return value" << endl; 
    }

在参数列表中使用void是可选的;接受空括号对并且更可取。这是唯一的一种方式来指示函数返回除返回void之外的值。

请注意,void实际上不是一种类型,因为您无法创建void变量;它是没有类型。正如您将在下一章中了解到的那样,您可以创建void类型的指针,但是您将无法使用这些指针指向的内存而不进行类型转换:要使用内存,您必须决定内存保存的数据的类型。

初始化器

初始化器在上一章中已经提到过,但我们将在这里更深入地讨论。对于内置类型,您必须在使用变量之前初始化变量。对于自定义类型,类型可能定义默认值,但在这样做时会出现一些问题,这将在第六章中进行介绍,

在 C的所有版本中,有三种初始化内置类型的方式:赋值、函数语法或调用构造函数。在 C11 中引入了另一种初始化变量的方式:通过列表初始化进行构造。这四种方式如下所示:

    int i = 1; 
    int j = int(2); 
    int k(3); 
    int m{4};

这三种方式中的第一种是最清晰的;它使用易于理解的语法显示变量正在初始化为一个值。第二个示例通过调用类型来初始化变量,就好像它是一个函数一样。第三个示例调用int类型的构造函数。这是初始化自定义类型的典型方式,因此最好将此语法保留给自定义类型。

第四种语法是 C++11 中的新语法,并使用花括号({})之间的初始化列表初始化变量。稍微令人困惑的是,您还可以使用与分配给单个项目列表相同的语法来初始化内置类型:

    int n = { 5 };

这真的让事情变得混乱,类型n是一个整数,而不是数组。回想一下,在上一章中,我们创建了一个包含 The Beatles 的出生日期的数组:

    int birth_years[] = { 1940, 1942, 1943, 1940 };

这将创建一个包含四个整数的数组;每个项目的类型为int,但数组变量的类型为int*。该变量指向保存四个整数的内存。同样,您还可以将变量初始化为一个项目的数组:

    int john[] = { 1940 };

这正是 C++11 允许初始化单个整数的初始化代码。此外,相同的语法用于初始化记录类型(structs)的实例,增加了关于语法意义的另一层潜在混淆。

最好避免使用花括号语法进行变量初始化,而将其专门用于初始化列表。然而,这种语法在类型转换方面有一些优势,稍后会解释。

花括号语法可以用于为 C标准库中的任何集合类提供初始值,以及用于 C数组。即使用于初始化集合对象,也存在混淆的可能。例如,考虑vector集合类。它可以保存通过一对尖括号(<>)提供的类型的集合。这个类的对象的容量可以随着向对象添加更多项目而增长,但你可以通过指定初始容量来优化其使用:

    vector<int> a1 (42); 
    cout << " size " << a1.size() << endl; 
    for (int i : a1) cout << i << endl;

这段代码的第一行表示:创建一个可以保存整数的vector对象,并开始为 42 个整数保留空间,每个整数初始化为零值。第二行将向控制台打印出向量的大小(42),第三行将向控制台打印出数组中的所有项目,它将打印出 42 个零值。

现在考虑以下情况:

    vector<int> a2 {42}; 
    cout << " size " << a2.size() << endl; 
    for (int i : a2) cout << i << endl;

这里只有一个变化:括号已经改为花括号,但这意味着初始化已经完全改变。第一行现在表示:创建一个可以保存整数的vector,并用单个整数 42 进行初始化。a2的大小为 1,最后一行将只打印一个值,42。

C++的强大之处在于应该很容易编写正确的代码,并且说服编译器帮助你避免错误。使用花括号进行单个项目初始化会增加难以发现错误的可能性。

默认值

内置类型的变量在首次使用前应该被初始化,但有一些情况下编译器会提供一个默认值。

如果你在文件范围或项目中全局声明一个变量,并且没有给它一个初始值,编译器会给它一个默认值。例如:

    int outside; 

    int main() 
    { 
        outside++; 
        cout << outside << endl; 
    }

这段代码将编译并运行,打印出一个值为 1;编译器已经将outside初始化为 0,然后递增为 1。以下代码将无法编译:

    int main() 
    { 
        int inside; 
        inside++; 
        cout << inside << endl; 
    }

编译器会抱怨增量运算符被用在一个未初始化的变量上。

在上一章中,我们看到编译器提供了默认值的另一个例子:static

    int counter() 
    { 
        static int count; 
        return ++count; 
    }

这是一个简单的函数,用于维护一个计数。变量count被标记为static存储类修饰符,意味着该变量与应用程序具有相同的生命周期(在代码启动时分配,在程序结束时释放);然而,它具有内部链接,意味着该变量只能在声明它的范围内使用,即counter函数。编译器将使用默认值 0 初始化count变量,因此第一次调用counter函数时将返回值 1。

C++11 的新初始化列表语法提供了一种声明变量并指定你希望它由编译器初始化为该类型的默认值的方法:

    int a {};

当阅读这段代码时,你必须知道int的默认值是什么(是零)。再次强调,将变量简单地初始化为一个值要容易得多,也更明确:

    int a = 0;

默认值的规则很简单:零值。整数和浮点数的默认值为 0,字符的默认值为\0bool的默认值为false,指针的默认值为常量nullptr

没有类型的声明

C++11 引入了一种机制,声明变量的类型应该根据初始化的数据来确定,即auto

这里有一个小混淆,因为在 C11 之前,auto关键字用于声明自动变量,即在函数中自动分配在堆栈上的变量。除了在文件范围内声明的变量或static变量之外,到目前为止本书中的所有其他变量都是自动变量,自动变量是最广泛使用的存储类(稍后解释)。由于它是可选的并且适用于大多数变量,auto关键字在 C中很少被使用,因此 C++11 利用了这一点,删除了旧的含义,并赋予了auto新的含义。

如果你正在使用 C11 编译器编译旧的 C代码,并且那个旧代码使用了auto,你会得到错误,因为新的编译器会假定auto将用于没有指定类型的变量。如果发生这种情况,只需搜索并删除每个auto实例;在 C11 之前的 C中,它是多余的,开发人员几乎没有理由使用它。

auto关键字意味着编译器应该创建一个与分配给它的数据类型相同的变量。变量只能有一个类型,编译器决定的类型是它需要的数据分配的类型,你不能在其他地方使用变量来保存不同类型的数据。因为编译器需要从初始化程序确定类型,这意味着所有auto变量必须被初始化:

    auto i  = 42;    // int 
    auto l  = 42l;   // long 
    auto ll = 42ll;  // long long 
    auto f  = 1.0f;  // float 
    auto d  = 1.0;   // double 
    auto c  = 'q';   // char 
    auto b  = true;  // bool

请注意,没有语法来指定整数值是单字节还是双字节,因此你不能以这种方式创建unsigned char变量或short变量。

这是auto关键字的一个微不足道的用法,你不应该这样使用。auto的威力在于你使用可能导致一些看起来相当复杂的类型的容器时:

    // #include <string> 
    // #include <vector> 
    // #include <tuple> 

    vector<tuple<string, int> > beatles; 
    beatles.push_back(make_tuple("John", 1940)); 
    beatles.push_back(make_tuple("Paul", 1942)); 
    beatles.push_back(make_tuple("George", 1943)); 
    beatles.push_back(make_tuple("Ringo", 1940)); 

    for (tuple<string, int> musician : beatles) 
    { 
        cout << get<0>(musician) << " " << get<1>(musician) << endl; 
    }

这段代码使用了我们之前使用过的vector容器,但是使用tuple存储了两个值。tuple类很简单;在尖括号之间的声明中声明了tuple对象中项目类型的列表。因此,tuple<string, int>声明表示对象将按顺序保存一个字符串和一个整数。make_tuple函数由 C++标准库提供,将创建一个包含两个值的tuple对象。push_back函数将项目放入向量容器中。在四次调用push_back函数之后,beatles变量将包含四个项目,每个项目都是一个带有姓名和出生年份的tuple

范围for循环遍历容器,并在每次循环中将musician变量分配给容器中的下一个项目。tuple中的值在for循环中的语句中打印到控制台。使用get参数化函数(来自<tuple>)访问tuple中的项目,尖括号中的参数指示从作为参数传递的tuple对象中获取的项目的索引(从零开始索引)。在这个例子中,对get<0>的调用获取了名字,然后是一个空格,然后get<1>获取了tuple中的年份项目。这段代码的结果是:

    John 1940 
    Paul 1942 
    George 1943 
    Ringo 1940

这段文字格式不佳,因为它没有考虑名称的长度。这可以通过第九章中解释的操作符来解决,使用字符串

再看一下for循环:

    for (tuple<string, int> musician : beatles) 
    { 
        cout << get<0>(musician) << " " << get<1>(musician) << endl; 
    }

音乐家的类型是tuple<string, int>;,这是一个相当简单的类型,随着你使用标准模板更多,你可能会得到一些复杂的类型(特别是当你使用迭代器时)。这就是auto变得有用的地方。下面的代码是相同的,但更容易阅读:

    for (auto musician : beatles) 
    { 
        cout << get<0>(musician) << " " << get<1>(musician) << endl; 
    }

音乐家变量仍然是有类型的,它是一个tuple<string, int>,但auto意味着你不必明确编写这个。

存储类

在声明变量时,你可以指定它的存储类,这表示变量的生存期、链接(其他代码可以访问它的内容)和内存位置。

您已经看到了一个存储类static,当应用于函数中的变量时,意味着该变量只能在该函数内访问,但其生存期与程序相同。然而,static可以用于在文件范围内声明的变量,这种情况下表明该变量只能在当前文件中使用,这被称为内部链接。如果在文件范围内声明的变量上省略static关键字,则具有外部链接,这意味着变量的名称对其他文件中的代码可见。static关键字可以用于类的数据成员和类中定义的方法,这两者都有有趣的影响,将在第六章 中进行描述。

static关键字表示该变量只能在当前文件中使用。extern关键字表示相反;变量(或函数)具有外部链接,并且可以在项目的其他文件中访问。在大多数情况下,您将在一个源文件中定义一个变量,然后在头文件中声明它为extern,以便在其他源文件中使用相同的变量。

最后一个存储类说明符是thread_local。这是 C++11 中的新功能,它只适用于多线程代码。本书不涉及线程,因此这里只会给出一个简要描述。

线程是执行和并发的单位。程序中可以有多个线程运行,可能有两个或更多个线程同时运行相同的代码。这意味着两个不同的执行线程可以访问和更改同一个变量。由于并发访问可能会产生不良影响,多线程代码通常涉及采取措施确保只有一个线程可以在任何时候访问数据。如果这样的代码没有小心编写,就有死锁的危险,其中线程的执行被暂停(在最坏的情况下,是无限期地)以独占访问变量,从而抵消了使用线程的好处。

thread_local存储类表示每个线程将有自己的变量副本。因此,如果两个线程访问同一个函数,并且该函数中的变量标记为thread_local,这意味着每个线程只看到它所做的更改。

您有时会在旧的 C代码中看到存储类register的使用。这现在已经不推荐使用了。它被用作向编译器提示变量对程序性能有重要影响,并建议编译器尽可能使用 CPU 寄存器来保存变量。编译器可以忽略这个建议。事实上,在 C11 中,编译器确实忽略了这个关键字;带有register变量的代码将编译而不会出现错误或警告,并且编译器将根据需要优化代码。

虽然它不是存储类说明符,但volatile关键字对编译器代码优化有影响。volatile关键字表示变量(可能通过直接内存访问DMA)到某些硬件)可以被外部操作改变,因此对编译器来说很重要不要应用任何优化。

还有一个存储类修饰符叫做mutable。这只能用于类成员,因此将在第六章 中进行介绍。

使用类型别名

有时类型的名称可能变得相当繁琐。如果您使用嵌套命名空间,类型的名称包括所有使用的命名空间。如果您定义参数化类型(本章迄今为止使用的示例是vectortuple),参数会增加类型的名称。例如,我们之前看到了一个用于音乐家姓名和出生年份的容器:

    // #include <string> 
    // #include <vector> 
    // #include <tuple> 

    vector<tuple<string, int> > beatles;

在这里,容器是vector,它包含tuple项,每个项将包含一个字符串和一个整数。为了使类型更易于使用,您可以定义一个预处理器符号:

    #define name_year tuple<string, int>

现在您可以在代码中使用name_year而不是tuple,预处理器将在编译代码之前用该类型替换符号:

    vector<name_year> beatles;

但是,由于#define是一个简单的搜索和替换,正如本书前面解释的那样,可能会出现问题。C++提供了typedef语句来为类型创建别名:

    typedef tuple<string, int> name_year_t; 
    vector<name_year_t> beatles;

在这里,为tuple<string, int>创建了一个名为name_year_t的别名。

使用typedef时,别名通常位于行末,前面是它的别名。这与#define相反,其中您要定义的符号在#define之后,后面是其定义。还要注意,typedef以分号结束。对于函数指针,情况变得更加复杂,您将在第五章 使用函数中看到。

现在,无论何时您想使用tuple,都可以使用别名:

    for (name_year_t musician : beatles) 
    { 
        cout << get<0>(musician) << " " << get<1>(musician) << endl; 
    }

您可以typedef别名:

    typedef tuple<string, int> name_year_t; 
    typedef vector<name_year_t> musician_collection_t; 
    musician_collection_t beatles2;

beatles2变量的类型是vector<tuple<string, int>>。重要的是要注意,typedef创建一个别名;它不会创建新类型,因此您可以在原始类型和其别名之间切换。

typedef关键字是在 C++中创建别名的一种成熟方式。

C++11 引入了另一种创建类型别名的方法,即using语句:

    using name_year = tuple<string, int>;

同样,这不会创建新类型,而是为相同类型创建新名称,从语义上讲,这与typedef相同。using语法可能比使用typedef更易读,它还允许您使用模板。

使用using方法创建别名比typedef更易读,因为赋值的使用遵循用于变量的约定,也就是说,左边的新名称用于=右边的类型。

在记录类型中聚合数据

通常,您将具有相关联且必须一起使用的数据:聚合类型。这样的记录类型允许您将数据封装到单个变量中。C++继承自 Cstructunion,作为提供记录的方式。

结构

在大多数应用程序中,您将希望将多个数据项关联在一起。例如,您可能希望定义一个时间记录,其中每个时间都有一个整数:指定时间的小时、分钟和秒。您可以这样声明它们:

    // start work 
    int start_sec = 0; 
    int start_min = 30; 
    int start_hour = 8; 

    // end work 
    int end_sec = 0 
    int end_min = 0; 
    int end_hour = 17;

这种方法变得相当繁琐且容易出错。没有封装,也就是说,_min变量可以独立于其他变量使用。当没有它所指的小时时,“小时过去的分钟”是否有意义?您可以定义一个结构,将这些项关联起来:

    struct time_of_day 
    { 
        int sec; 
        int min; 
        int hour; 
    };

现在,您已经将三个值作为一个记录的一部分,这意味着您可以声明此类型的变量;尽管您可以访问单个项目,但很明显数据与其他成员相关联:

    time_of_day start_work; 
    start_work.sec = 0; 
    start_work.min = 30; 
    start_work.hour = 8; 

    time_of_day end_work; 
    end_work.sec = 0; 
    end_work.min = 0; 
    end_work.hour = 17; 

    print_time(start_work); 
    print_time(end_work);

现在我们有两个变量:一个表示开始时间,另一个表示结束时间。struct的成员封装在struct内部,也就是说,您通过struct的实例访问成员。为此,您使用点运算符。在此代码中,start_work.sec表示您正在访问名为start_worktime_of_day结构的实例的sec成员。结构的成员默认为public,也就是说,struct外部的代码可以访问成员。

类和结构可以指示成员访问级别,第六章 将展示如何做到这一点。例如,可以将struct的某些成员标记为private,这意味着只有类型的成员才能访问成员。

调用名为print_time的辅助函数以将数据打印到控制台:

    void print_time(time_of_day time) 
    { 
        cout << setw(2) << setfill('0') << time.hour << ":"; 
        cout << setw(2) << setfill('0') << time.min << ":"; 
        cout << setw(2) << setfill('0') << time.sec << endl; 
    }

在这种情况下,使用setwsetfill操作器将下一个插入的项目的宽度设置为两个字符,并用零填充任何未填充的位置(更多细节将在第九章,“使用字符串”中给出;实际上,setw给出了下一个插入数据所占列的大小,setfill指定了所使用的填充字符)。

第五章,“使用函数”,将更详细地介绍将结构传递给函数的机制以及最有效的方法,但是为了本节的目的,我们将在这里使用最简单的语法。重要的是,调用者使用struct关联了三个数据项,并且所有数据项可以作为一个单元传递给函数。

初始化

有几种初始化结构实例的方法。前面的代码显示了一种方法:使用点运算符访问成员,并为其赋值。您还可以通过一个特别提供的名为构造函数的函数为struct的实例分配值。由于有关如何命名构造函数以及您可以在其中执行的特殊规则,这将留到第六章,“类”。

您还可以使用列表初始化程序语法使用大括号({})初始化结构。大括号中的项目应与struct的成员按照声明的成员顺序匹配。如果提供的值少于成员数量,则其余成员将初始化为零。实际上,如果在大括号之间不提供任何项目,则所有成员都将设置为零。如果提供的初始化程序多于成员数量,则会出错。因此,使用先前定义的time_of_day记录类型:

    time_of_day lunch {0, 0, 13}; 
    time_of_day midnight {}; 
    time_of_day midnight_30 {0, 30};

在第一个示例中,lunch变量被初始化为下午 1 点。请注意,因为hour成员被声明为类型中的第三个成员,所以它是使用初始化列表中的第三个项目进行初始化的。在第二个示例中,所有成员都设置为零,当然,零小时是午夜。第三个示例提供了两个值,因此这些值用于初始化secmin

您可以有一个struct的成员本身是一个struct,并且可以使用嵌套的大括号进行初始化:

    struct working_hours 
    { 
        time_of_day start_work; 
        time_of_day end_work; 
    }; 

    working_hours weekday{ {0, 30, 8}, {0, 0, 17} }; 
    cout << "weekday:" << endl; 
    print_time(weekday.start_work); 
    print_time(weekday.end_work);

结构字段

结构可以有最小为单个位的成员,称为位字段。在这种情况下,您声明一个整数成员,该成员将占用成员的位数。您可以声明未命名的成员。例如,您可能有一个结构,其中包含有关项目长度以及项目是否已更改(脏)的信息。此引用的项目的最大大小为 1,023,因此您需要一个宽度至少为 10 位的整数来保存这个信息。您可以使用unsigned short来保存长度和脏信息:

    void print_item_data(unsigned short item) 
    { 
        unsigned short size = (item & 0x3ff); 
        char *dirty = (item > 0x7fff) ? "yes" : "no"; 

        cout << "length " << size << ", "; 
        cout << "is dirty: " << dirty << endl; 
    }

此代码将信息分开,然后将其打印出来。这样的位图对代码来说非常不友好。您可以使用struct来保存这些信息,使用unsigned short来保存 10 位长度信息,使用bool来保存脏信息。使用位字段,您可以定义结构如下:

    struct item_length 
    { 
        unsigned short len : 10; 
        unsigned short : 5; 
        bool dirty : 1; 
    };

len成员标记为unsigned short,但只需要 10 位,因此使用冒号语法进行了说明。同样,一个布尔值可以仅用一个位来保存。结构指示两个值之间有五位未使用,因此没有名称。

字段只是一种便利。尽管看起来item_length结构应该只占用 16 位(unsigned short),但不能保证编译器会这样做。如果您从文件或网络流接收到unsigned short,则必须自己提取位:

    unsigned short us = get_length(); 
    item_length slen; 
    slen.len = us & 0x3ff; 
    slen.dirty = us > 0x7fff;

使用结构名

在某些情况下,你可能需要在实际定义之前使用类型。只要你不使用成员,你可以在定义之前声明类型:

    struct time_of_day; 
    void print_day(time_of_day time);

这可以在头文件中声明,在那里它说有一个在其他地方定义的函数,它接受一个time_of_day记录并将其打印出来。要能够声明print_day函数,你必须已经声明了time_of_day名称。time_of_day结构必须在代码的其他地方定义,然后才能定义函数,否则你将会得到一个未定义类型的错误。

然而,有一个例外:在类型完全声明之前,类型可以保存指向相同类型实例的指针。这是因为编译器知道指针的大小,所以它可以为成员分配足够的内存。只有在整个类型定义之后,你才能创建类型的实例。这个经典的例子是链表,但由于这需要使用指针和动态分配,这将留到下一章节。

确定对齐

结构的一个用途是,如果你知道数据在内存中的存储方式,你可以将结构作为内存块处理。如果你有一个映射到内存中的硬件设备,不同的内存位置指向控制或返回设备值的值。访问设备的一种方式是定义一个与设备的直接内存访问到 C++类型的内存布局匹配的结构。此外,结构对于文件或需要通过网络传输的数据包也是有用的:你操作结构,然后将结构占用的内存复制到文件或网络流中。

结构的成员在内存中按照它们在类型中声明的顺序排列。项将占用至少每个类型所需的内存。成员可能占用的内存比类型所需的内存更多,这是一种叫做对齐的机制。

编译器将以最有效的方式将变量放置在内存中,无论是在内存使用还是访问速度方面。各种类型将对齐到对齐边界。例如,32 位整数将对齐到四字节边界,如果下一个可用的内存位置不在这个边界上,编译器将跳过几个字节,并将整数放在下一个对齐边界上。你可以使用alignof运算符传递类型名称来测试特定类型的对齐方式:

    cout << "alignment boundary for int is "  0
        << alignof(int) << endl;                     // 4 
    cout << "alignment boundary for double is "  
        << alignof(double) << endl;                  // 8

int的对齐方式是 4,这意味着int变量将被放置在内存中的下一个四字节边界上。double的对齐方式是 8,这是有道理的,因为在 Visual C++中,double占用八个字节。到目前为止,alignof的结果看起来与sizeof是一样的;然而,事实并非如此。

    cout << "alignment boundary for time_of_day is "  
        << alignof(time_of_day) << endl;             // 4

这个例子打印了time_of_day结构的对齐方式,我们之前定义为三个整数。这个struct的对齐方式是 4,也就是说,struct中最大项的对齐方式。这意味着time_of_day的实例将被放置在 4 字节边界上;它并没有说明time_of_day变量内的项将如何对齐。

例如,考虑以下struct,它有四个成员,分别占用一、二、四和八个字节:

    struct test 
    { 
        uint8_t  uc; 
        uint16_t us; 
        uint32_t ui; 
        uint64_t ull; 
    }

编译器会告诉你对齐是 8(最大项ull的对齐),但大小是 16,这可能看起来有点奇怪。如果每个项都对齐在 8 字节边界上,那么大小将是 32(四倍八)。如果项被存储在内存中并尽可能有效地打包,那么大小将是 15。相反,实际发生的是第二个项在两字节边界上对齐,这意味着在ucus之间有一个字节的未使用空间。

如果要将内部项对齐到与uint32_t变量使用的相同边界上,可以使用alignas标记一个项,并给出所需的对齐方式。请注意,因为 8 大于 4,因此在 8 字节边界上对齐的任何项也将在 4 字节边界上对齐:

    struct test 
    { 
        uint8_t  uc; 
        alignas(uint32_t) uint16_t us; 
        uint32_t ui; 
        uint64_t ull; 
    }

uc项已经对齐在 4 字节边界上(alignof(test)将为 8),它将占用一个字节。us成员是一个uint16_t,但标有alignas(uint32_t),表示它应该与uint32_t以相同的方式对齐,即在 4 字节边界上。这意味着ucus都将在提供填充的 4 字节边界上。当然,ui成员也将对齐在 4 字节边界上,因为它是一个uint32_t

如果struct只有这三个成员,那么大小将是 12。然而,struct还有另一个成员,即 8 字节的ull成员。这必须对齐在 8 字节边界上,这意味着从struct的开始到 16 字节,因此在uiull之间需要有 4 字节的填充。因此,test的大小现在报告为 24:ucus各占 4 字节(因为接下来的项ui必须对齐在下一个 4 字节边界上),ull占 8 字节(因为它是一个 8 字节整数),ui占 8 字节,因为接下来的项(ull)必须在下一个 8 字节边界上。

以下图表显示了test类型的各个成员在内存中的位置:

您不能使用alignas来放宽对齐要求,因此您不能将uint64_t变量标记为在不是 8 字节边界的情况下对齐在两字节边界上。

在大多数情况下,您不需要担心对齐;但是,如果您正在访问内存映射设备或来自文件的二进制数据,如果您可以直接将这些数据映射到一个struct,那将非常方便,在这种情况下,您将发现必须非常注意对齐。这被称为纯旧数据,您经常会看到struct被称为POD 类型

POD 是一个非正式的描述,有时用来描述具有简单构造并且没有虚拟成员的类型(参见第六章,和第七章,面向对象编程简介)。标准库提供了<type_traits>中的一个名为is_pod的函数,用于测试这些成员的类型。

在同一内存中存储数据的联合

联合是一个结构,其中所有成员占用相同的内存。这种类型的大小是最大成员的大小。由于联合只能容纳一个数据项,它是一种以多种方式解释数据的机制。

联合的一个示例是用于在 Microsoft 的组件对象模型COM)中的对象链接和嵌入OLE)对象之间传递数据的VARIANT类型。VARIANT类型可以容纳 COM 能够在 OLE 对象之间传输的任何数据类型的数据。有时,OLE 对象将在同一个进程中,但它们也可能在同一台机器上的不同进程中,或者在不同的机器上。COM 保证可以在不需要开发人员提供任何额外的网络代码的情况下传输VARIANT。结构很复杂,但这里显示了一个编辑过的版本:

    // edited version 
    struct VARIANT 
    { 
        unsigned short vt; 
        union 
        { 
            unsigned char bVal; 
            short iVal; 
            long lVal; 
            long long llVal; 
            float fltVal; 
            double dblVal; 
       }; 
    };

注意,您可以使用没有名称的联合:这是一个匿名的union,从成员访问的角度来看,您访问联合的成员就像访问包含它的VARIANT的成员一样。union包含可以在 OLE 对象之间传输的每种类型的成员,并且vt成员指示使用哪种类型。当您创建VARIANT实例时,必须将vt设置为适当的值,然后初始化相关的成员:

    enum VARENUM 
    { 
        VT_EMPTY = 0,  
        VT_NULL = 1,  
        VT_UI1 = 17,  
        VT_I2 = 2,  
        VT_I4 = 3,  
        VT_I8 = 20, 
        VT_R4 = 4,  
        VT_R8 = 5  
    };

这条记录确保只使用所需的内存,并且从一个进程传输数据到另一个进程的代码将能够读取vt成员,以确定数据需要如何被处理以便传输:

    // pseudo code, real VARIANT should not be handled like this 
    VARIANT var {}; // clear all items 
    var.vt = VT_I4; // specify the type 
    var.lVal = 42;  // set the appropriate member 
    pass_to_object(var);

请注意,你必须自律,只初始化适当的成员。当你的代码接收到一个VARIANT时,你必须读取vt来查看应该使用哪个成员来访问数据。

一般来说,当使用联合时,你应该只访问你初始化的项目:

    union d_or_i {double d; long long i}; 
    d_or_i test; 
    test.i = 42; 
    cout << test.i << endl; // correct use 
    cout << test.d << endl; // nonsense printed

访问运行时类型信息

C++提供了一个名为typeid的运算符,它将在运行时返回关于变量(或类型)的类型信息。运行时类型信息RTTI)在你使用可以以多态方式使用的自定义类型时很重要;具体细节将留到后面的章节。RTTI 允许你在运行时检查变量的类型并相应地处理变量。RTTI 通过一个type_info对象返回(在<typeinfo>头文件中):

    cout << "int type name: " << typeid(int).name() << endl; 
    int i = 42; 
    cout << "i type name: " << typeid(i).name() << endl;

在这两种情况下,你会看到int作为类型被打印出来。type_info类定义了比较运算符(==!=),所以你可以比较类型:

    auto a = i; 
    if (typeid(a) == typeid(int)) 
    { 
        cout << "we can treat a as an int" << endl; 
    }

确定类型限制

<limits>头文件包含一个名为numeric_limits的模板类,通过为每种内置类型提供的特化来使用。使用这些类的方法是在尖括号中提供你想要获取信息的类型,然后使用作用域解析运算符(::)在类上调用static成员。(有关类上的static函数的完整详情将在第六章中给出,)。以下将int类型的限制打印到控制台:

    cout << "The int type can have values between "; 
    cout << numeric_limits<int>::min() << " and  "; 
    cout << numeric_limits<int>::max() << endl;

在类型之间进行转换

即使你非常努力地在你的代码中使用正确的类型,最终你会发现你必须在不同类型之间进行转换。例如,你可能正在使用返回特定类型值的库函数,或者你可能正在从外部来源读取与你的例程不同类型的数据。

对于内置类型,有关不同类型之间的转换有标准规则,其中一些将是自动的。例如,如果你有一个表达式a + b,并且ab是不同的类型,那么,如果可能的话,编译器将自动将一个变量的值转换为另一个变量的类型,并调用该类型的+运算符。

在其他情况下,你可能需要强制一种类型转换为另一种类型,以便调用正确的运算符,这将需要某种类型的转换。C允许你使用类似 C 的转换,但这些转换没有运行时测试,因此最好使用 C转换,它具有各种级别的运行时检查和类型安全性。

类型转换

内置转换可能有两种结果:提升或缩小。提升是指将较小的类型提升为较大的类型,不会丢失数据。缩小转换发生在将较大类型的值转换为较小类型的值时,可能会丢失数据。

提升转换

在混合类型表达式中,编译器将尝试将较小的类型提升为较大的类型。因此,charshort可以在需要int的表达式中使用,因为它可以被提升为较大的类型而不会丢失数据。

考虑一个声明为接受int参数的函数:

    void f(int i);

我们可以写:

    short s = 42; 
    f(s); // s is promoted to int

这里变量s被悄悄地转换为int。有些情况可能看起来很奇怪:

    bool b = true; 
    f(b); // b is promoted to int

再次强调,转换是悄悄进行的。编译器假设你知道自己在做什么,你的意图是希望false被视为 0,true被视为 1。

缩小转换

在某些情况下,缩小会发生。一定要非常小心,因为这会丢失数据。在下面的示例中,尝试将double转换为int

    int i = 0.0;

这是允许的,但编译器会发出警告:

C4244: 'initializing': conversion from 'double' to 'int', possible loss of data

这段代码显然是错误的,但这个错误并不是一个错误,因为它可能是有意的。例如,在下面的代码中,我们有一个函数,它有一个浮点参数,并且在例程中,参数用来初始化一个int

    void calculation(double d) 
    { 
        // code 
        int i = d; 

        // use i 
        // other code 
    }

这可能是有意的,但因为会丢失精度,你应该记录为什么这样做。至少使用一个转换操作符,这样很明显你理解了这个行为的后果。

缩小到 bool

如前所述,指针、整数和浮点值可以在非零值转换为true,零值转换为false的地方隐式转换为bool。这可能导致一个难以注意到的严重错误:

    int x = 0; 
    if (x = 1) cout << "not zero" << endl; 
    else       cout << "is zero" << endl;

在这里,编译器看到赋值表达式x = 1,这是一个 bug;它应该是比较x == 1。然而,这是有效的 C++,因为表达式的值是 1,编译器会将其转换为truebool值。这段代码将编译而不会有警告,不仅会产生一个与你期望相反的结果(你会在控制台上看到not zero打印出来),而且赋值会改变变量的值,从而在整个程序中传播错误。

通过养成一个习惯,总是构造一个比较,使得潜在赋值的 rvalue 在左边,很容易避免这个 bug。在比较中,将没有 rvalue 或 lvalue 的概念,因此这使用编译器来捕捉一个意外的赋值:

    if (1 = x) // error 
    cout << "not zero" << endl;

转换有符号类型

有符号到无符号的转换可能会发生,导致意外的结果。例如:

    int s = -3; 
    unsigned int u = s;

unsigned short变量将被赋值为0xfffffffd,即 3 的二进制补码。这可能是你想要的结果,但这是一个奇怪的方式来得到它。

有趣的是,如果你尝试比较这两个变量,编译器会发出一个警告:

    if (u < s) // C4018 
    cout << "u is smaller than s" << endl;

这里给出的 Visual C++警告 C4018 是'<': signed/unsigned mismatch,它表示你不能比较有符号和无符号类型,这样做需要一个转换。

转换

在某些情况下,你需要在不同类型之间进行转换。例如,这可能是因为数据以不同的类型提供给你用来处理它的例程。你可能有一个库,它将浮点数作为float处理,但你的数据是以double输入的。你知道转换会丢失精度,但知道这对最终结果几乎没有影响,所以你不希望编译器警告你。你想告诉编译器,将一种类型强制转换为另一种类型是可以接受的。

下表总结了 C++11 中可以使用的各种转换操作:

名称 语法
构造 {}
移除const要求 const_cast
没有运行时检查的转换 static_cast
类型的位转换 reinterpret_cast
在类指针之间进行转换,带有运行时检查 dynamic_cast
C 风格 ()
函数风格 ()

放弃 const-ness

如上一章所述,const修饰符用于指示编译器一个项目不会改变,并且你的代码尝试改变项目是一个错误。还有另一种使用这个修饰符的方法,将在下一章中探讨。当const应用于指针时,它表示指针指向的内存不能被改变:

    char *ptr = "0123456"; 
    // possibly lots of code 
    ptr[3] = '\0'; // RUNTIME ERROR!

这段糟糕的代码告诉编译器创建一个值为0123456的字符串常量,然后将这个内存的地址放入字符串指针ptr。最后一行尝试写入字符串。这将编译,但会在运行时导致访问冲突。将const应用于指针声明将确保编译器检查这种情况:

    const char *ptr = "0123456";

更典型的情况是将const应用于函数参数的指针,意图是相同的:它向编译器指示指针指向的数据应该是只读的。然而,可能存在你想要删除这样一个指针的const属性的情况,这可以使用const_cast操作符来实现:

    char * pWriteable = const_cast<char *>(ptr); 
    pWriteable[3] = '\0';

语法很简单。要转换为的类型在尖括号(<>)中给出,变量(一个const指针)在括号中提供。

您还可以将指针转换为const指针。这意味着您可以有一个指针用于访问内存,以便您可以对其进行写入,然后在进行更改后,您可以创建一个指向内存的const指针,从而通过指针使内存只读。

显然,一旦你取消指针的 const 属性,你就要对写入内存造成的损害负责,所以你的代码中的const_cast操作符是你在代码审查期间检查代码的一个良好标记。

不带运行时检查的转换

大多数转换都是使用static_cast操作符执行的,它可以用于将指针转换为相关的指针类型,以及在不同数值类型之间进行转换。不执行运行时检查,因此您应该确信转换是可接受的:

    double pi = 3.1415; 
    int pi_whole = static_cast<int>(pi);

在这里,double 被转换为 int,这意味着小数部分被丢弃。通常编译器会发出警告,表示数据丢失,但static_cast操作符表明这是你的意图,因此不会发出警告。

该操作符通常用于将void*指针转换为类型化指针。在下面的代码中,unsafe_d函数假设参数是指向内存中 double 值的指针,因此它可以将void*指针转换为double*指针。与pd指针一起使用的*操作符解引用指针以提供它指向的数据。因此,*pd表达式将返回一个double

    void unsafe_d(void* pData) 
    { 
       double* pd = static_cast<double*>(pData); 
       cout << *pd << endl; 
    }

这是不安全的,因为您依赖调用者确保指针实际上指向double。可以这样调用它:

    void main() 
    { 
       double pi = 3.1415; 
       unsafe_d(&pi);       // works as expected 

       int pi_whole = static_cast<int>(pi); 
       unsafe_d(&pi_whole); // oops! 
    }

&操作符将操作数的内存地址作为类型化指针返回。在第一种情况下,获得一个double*指针并传递给unsafe_d函数。编译器将自动将此指针转换为void*参数。编译器会自动执行此操作,而不检查指针在函数中是否被正确使用。这可以通过对unsafe_d的第二次调用来说明,在这次调用中,int*指针被转换为void*参数,然后在unsafe_d函数中,它被static_cast转换为double*,即使指针指向int。因此,解引用将返回不可预测的数据,cout将打印无意义的内容。

不带运行时检查的转换

reinterpret_cast操作符允许将一个类型的指针转换为另一个类型的指针,并且可以从指针转换为整数,从整数转换为指针:

    double pi = 3.1415; 
    int i = reinterpret_cast<int>(&pi); 
    cout << hex << i << endl;

static_cast不同,此操作符始终涉及指针:在指针之间进行转换,从指针转换为整数类型,或者从整数类型转换为指针。在这个例子中,将指向double变量的指针转换为int,并将值打印到控制台。实际上,这打印出了变量的内存地址。

带有运行时检查的转换

dynamic_cast操作符用于在相关类之间转换指针,因此将在第六章 中进行解释。此操作符涉及运行时检查,因此只有在操作数可以转换为指定类型时才执行转换。如果转换不可能,则操作符返回nullptr,使您有机会仅使用指向该类型的实际对象的转换指针。

使用列表初始化符进行转换

C++编译器将允许一些隐式转换;在某些情况下,它们可能是有意的,在某些情况下可能不是。例如,以下代码类似于之前显示的代码:变量初始化为double值,然后稍后在代码中用于初始化int。编译器将执行转换,并发出警告:

    double pi = 3.1415; 
    // possibly loss of code 
    int i = pi;

如果忽略警告,则可能不会注意到这种精度损失,这可能会导致问题。解决此问题的一种方法是使用花括号进行初始化:

    int i = {pi};

在这种情况下,如果pi可以在不损失的情况下转换为int(例如,如果pishort),则代码将甚至不会发出警告而编译。但是,如果pi是不兼容的类型(在这种情况下是double),编译器将发出错误:

C2397: conversion from 'double' to 'int' requires a narrowing conversion

这是一个有趣的例子。char类型是一个整数,但来自osteam类的char<<运算符将char变量解释为字符,而不是数字,如下所示:

    char c = 35; 
    cout << c << endl;

这将在控制台上打印#,而不是 35,因为 35 是#的 ASCII 码。要使变量被视为数字,可以使用以下之一:

    cout << static_cast<short>(c) << endl; 
    cout << short{ c } << endl;

正如您所看到的,第二个版本(构造)与第一个版本一样可读,但比第一个版本更短。

使用 C 转换

最后,您可以使用 C 样式转换,但这些仅提供以便您可以编译旧代码。您应该改用 C++转换之一。为了完整起见,这里显示了 C 样式转换:

    double pi = 3.1415; 
    float f1 = (float)pi; 
    float f2 = float(pi);

有两个版本:第一个转换运算符将括号放在要转换的类型周围,而在第二个版本中,转换看起来像函数调用。在这两种情况下,最好使用static_cast,以便进行编译时检查。

使用 C++类型

在本章的最后部分,我们将开发一个命令行应用程序,允许您以混合字母数字和十六进制格式打印文件的内容。

应用程序必须以文件名运行,但是您可以选择指定要打印多少行。该应用程序将在控制台上打印文件的内容,每行 16 个字节。左侧显示十六进制表示,右侧显示可打印表示(如果字符不在可打印的非扩展 ASCII 范围内,则显示一个点)。

C:\Beginning_C++下创建一个名为Chapter_03的新文件夹。启动 Visual C并创建一个 C源文件,并将其保存到刚创建的文件夹中,命名为hexdump.cpp。添加一个简单的main函数,允许应用程序接受参数,并使用 C++流提供输入和输出支持:

    #include <iostream> 

    using namespace std; 

    int main(int argc, char* argv[]) 
    { 
    }

该应用程序最多有两个参数:第一个是文件名,第二个是要在命令行上打印的 16 字节块的数量。这意味着您需要检查参数是否有效。首先添加一个usage函数,以提供应用程序参数,并且如果使用非空参数调用,则打印出错误消息:

    void usage(const char* msg) 
    { 
        cout << "filedump filename blocks" << endl; 
        cout << "filename (mandatory) is the name of the file to dump"  
            << endl; 
        cout << "blocks (option) is the number of 16 byte blocks " 
            << endl; 
        if (nullptr == msg) return; 
        cout << endl << "Error! "; 
        cout << msg << endl; 
    }

main函数之前添加此函数,以便您可以从那里调用它。该函数可以使用指向 C 字符串的指针或nullptr来调用。参数是const,指示编译器在函数中不会更改字符串,因此如果有任何尝试更改字符串,编译器将生成错误。

将以下行添加到main函数中:

    int main(int argc, char* argv[]) 
    { 
 if (argc < 2) { usage("not enough parameters"); return 1; } if (argc > 3) { usage("too many parameters"); return 1; } // the second parameter is file name string filename = argv[1]; 
    }

编译文件并确认没有拼写错误。由于此应用程序使用 C标准库,因此必须使用/EHsc开关提供对 C异常的支持:

cl /EHsc hexdump.cpp

您可以通过从命令行调用该应用程序并使用零、一个、两个,然后三个参数来测试它。确认该应用程序只允许在命令行上使用一个或两个参数进行调用(实际上意味着使用两个或三个参数,因为argcargv包括应用程序名称)。

下一个任务是确定用户是否提供了一个数字来指示要将多少个 16 字节块转储到控制台,如果是的话,将命令行提供的字符串转换为整数。这段代码将使用istringstream类将字符串转换为数字,所以你需要包含定义这个类的头文件。在文件的顶部添加以下内容:

    #include <iostream>
 #include <sstream>

filename变量声明之后添加以下突出显示的代码:

    string filename = argv[1]; 
 int blocks = 1;  // default value if (3 == argc) { // we have been passed the number of blocks istringstream ss(argv[2]); ss >> blocks; if (ss.fail() || 0 >= blocks) { // cannot convert to a number usage("second parameter: must be a number," "and greater than zero"); return 1; } }

默认情况下,应用程序将从文件中转储一行数据(最多 16 字节)。如果用户提供了不同数量的行,字符串格式的数字将通过istringstream对象转换为整数。这个对象被初始化为参数,然后从流对象中提取数字。如果用户输入了零值,或者输入的值无法解释为字符串,代码将打印错误消息。错误字符串被分成两行,但仍然是一个字符串。

注意,if语句使用了短路运算;也就是说,如果表达式的第一部分(ss.fail(),表示转换失败)为true,那么第二个表达式(0 >= blocks,也就是blocks必须大于零)将不会被评估。

编译这段代码并尝试几次。例如:

 hexdump readme.txt
 hexdump readme.txt 10
 hexdump readme.txt 0
 hexdump readme.txt -1

前两个应该可以正常运行;后两个应该会生成错误。

不用担心readme.txt不存在,因为它只是作为一个测试参数存在。

接下来,你将添加打开文件并处理它的代码。由于你将使用ifstream类从文件中输入数据,所以在文件的顶部添加以下头文件:

    #include <iostream> 
    #include <sstream> 
 #include <fstream>

然后在main函数的底部添加打开文件的代码:

    ifstream file(filename, ios::binary); 
    if (!file.good()) 
    { 
        usage("first parameter: file does not exist"); 
        return; 
    } 

    while (blocks-- && read16(file) != -1);  
    file.close();

第一行创建了一个名为file的流对象,并将其附加到filename中给定路径的文件。如果找不到文件,good函数将返回false。这段代码使用!运算符否定值,所以如果文件不存在,则执行if后面大括号中的语句。如果文件存在并且ifstream对象可以打开它,数据将以 16 字节的方式一次读取一次在while循环中。请注意,在这段代码的末尾,file对象上调用了close函数。当你完成资源的使用时,显式关闭资源是一个好的做法。

read16函数将按字节访问文件,包括不可打印的字节,因此像\r\n这样的控制字符没有特殊含义,并且仍然会被读取。然而,流类以特殊方式处理\r字符:这被视为一行的结束,通常流会默默地消耗这个字符。为了防止这种情况,我们使用ios::binary以二进制模式打开文件。

再次审查while语句:

    while (blocks-- && read16(file) != -1);

这里有两个表达式。第一个表达式递减blocks变量,该变量保存将要打印的 16 字节块的数量。后缀递减意味着表达式的值是递减之前的变量值,所以如果在blocks为零时调用表达式,整个表达式会被短路,while循环结束。如果第一个表达式非零,则调用read16函数,如果返回值为-1(到达文件结尾),则循环结束。循环的实际工作发生在read16函数内部,所以while循环语句是空语句。

现在你必须在main函数的上面实现read16函数。这个函数将使用一个常量来定义每个块的长度,所以在文件的顶部附近添加以下声明:

    using namespace std; 
 const int block_length = 16;

main函数之前,添加以下代码:

    int read16(ifstream& stm) 
    { 
        if (stm.eof()) return -1; 
        int flags = cout.flags(); 
        cout << hex; 

        string line; 

        // print bytes 

        cout.setf(flags); 
        return line.length(); 
    }

这只是函数的框架代码。你将在一会儿添加更多的代码。

这个函数每次最多读取 16 个字节,并将这些字节的内容打印到控制台。返回值是读取的字节数,如果到达文件末尾则返回-1。注意用于将流对象传递给函数的语法。这是一个引用,一种指向实际对象的指针类型。之所以使用引用是因为如果不这样做,函数将得到流的副本。引用将在下一章中介绍,使用对象引用作为函数参数将在第五章中介绍,使用函数

这个函数测试的第一行是验证是否已经到达文件末尾,如果是,就不能再进行处理,返回-1 的值。代码将操作cout对象(例如插入hex操纵器);所以你总是知道函数外部的cout对象的状态,函数确保在返回时cout对象的状态与调用函数时相同。通过调用flags函数获取cout对象的初始格式状态,并在函数返回之前通过调用setf函数重置cout对象。

这个函数什么也不做,所以可以安全地编译文件并确认没有拼写错误。

read16函数有三个作用:

  1. 它按字节读取,最多 16 个字节。

  2. 它打印出每个字节的十六进制值。

  3. 它打印出字节的可打印值。

这意味着每行有两部分:左边是十六进制部分,右边是可打印部分。用突出显示的代码替换函数中的注释:

    string line; 
 for (int i = 0; i < block_length; ++i) { // read a single character from the stream unsigned char c = stm.get(); if (stm.eof()) 
            break; // need to make sure that all hex are printed   
        // two character padded with zeros cout << setw(2) << setfill('0'); cout << static_cast<short>(c) << " "; if (isprint(c) == 0) line += '.'; else                 line += c; }

for循环将最多循环block_length次。第一条语句从流中读取一个字符。这个字节被作为原始数据读取。如果get发现流中没有更多的字符,它将在流对象中设置一个标志,并通过调用eof函数进行测试。如果eof函数返回true,意味着已经到达文件末尾,所以for循环结束,但函数不会立即返回。原因是可能已经读取了一些字节,所以必须进行更多的处理。

循环中的其余语句有两个作用:

  • 有语句在控制台打印字符的十六进制值

  • 有一条语句将字符以可打印形式存储在line变量中。

我们已经将cout对象设置为输出十六进制值,但如果字节小于 0x10,则值不会以零为前缀打印。为了获得这种格式,我们插入setw操纵器,表示插入的数据将占用两个字符位置,并且setfill表示使用0字符填充字符串。这两个操纵器在<iomanip>头文件中可用,所以将它们添加到文件的顶部:

    #include <fstream> 
 #include <iomanip>

通常,当你将一个char插入流中时,字符值会显示出来,所以char变量被转换为short,以便流打印十六进制数值。最后,每个项目之间打印一个空格。

for循环中的最后几行在这里显示:

    if (isprint(c) == 0) line += '.'; 
    else                 line += c;

这段代码检查字节是否是可打印字符(" "到"~")使用isprint宏,如果字符是可打印的,它就被追加到line变量的末尾。如果字节不可打印,则在line变量的末尾追加一个点作为占位符。

到目前为止的代码将按顺序将字节的十六进制表示打印到控制台,唯一的格式是字节之间的空格。如果要测试代码,可以编译并在源文件上运行它:

hexdump hexdump.cpp 5

你会看到一些难以理解的东西,比如下面的内容:

    C:\Beginning_C++\Chapter_03>hexdump hexdump.cpp 5 
23 69 6e 63 6c 75 64 65 20 3c 69 6f 73 74 72 65 61 6d 3e 0d 0a 
23 69 6e 63 6c 75 64 65 20 3c 73 73 74 72 65 61 6d 3e 0d 0a 23 
69 6e 63 6c 75 64 65 20 3c 66 73 74 72 65 61 6d 3e 0d 0a 23 69 
6e 63 6c 75 64 65 20 3c 69 6f 6d 61 6e 69 70 3e 0d

23的值是#,20是空格,0d0a是回车和换行。

现在我们需要打印line变量中的字符表示,并进行一些格式化,并添加换行符。在for循环之后,添加以下内容:

    string padding = " "; 
    if (line.length() < block_length) 
    { 
        padding += string( 
            3 * (block_length - line.length()), ' '); 
    } 

    cout << padding; 
    cout << line << endl;

十六进制显示和字符显示之间至少会有两个空格。一个空格来自for循环中打印的最后一个字符,第二个空格在padding变量的初始化中提供。

每行的最大字节数应为 16 字节(block_length),因此控制台上打印 16 个十六进制值。如果读取的字节数少于 16 个,则需要额外的填充,以便在连续的行上字符表示对齐。实际读取的字节数将是通过调用length函数获得的line变量的长度,因此缺少的字节数是表达式block_length - line.length()。由于每个十六进制表示占用三个字符(两个用于数字,一个用于空格),所需的填充是缺少字节数的三倍。为了创建适当数量的空格,将使用字符串构造函数调用两个参数:复制的数量和要复制的字符。

最后,这个填充字符串被打印到控制台,后面是字节的字符表示。

此时,您应该能够编译代码而不会出现错误或警告。当您在源文件上运行代码时,您应该会看到类似以下的内容:

    C:\Beginning_C++\Chapter_03>hexdump hexdump.cpp 5 
23 69 6e 63 6c 75 64 65 20 3c 69 6f 73 74 72 65  #include <iostre
61 6d 3e 0d 0a 23 69 6e 63 6c 75 64 65 20 3c 73  am>..#include <s
73 74 72 65 61 6d 3e 0d 0a 23 69 6e 63 6c 75 64  stream>..#includ
65 20 3c 66 73 74 72 65 61 6d 3e 0d 0a 23 69 6e  e <fstream>..#in
63 6c 75 64 65 20 3c 69 6f 6d 61 6e 69 70 3e 0d  clude <iomanip>.

现在这些字节更有意义了。由于应用程序不会更改转储的文件,因此可以放心地在二进制文件上使用此工具,包括本身:

    C:\Beginning_C++\Chapter_03>hexdump hexdump.exe 17 
4d 5a 90 00 03 00 00 00 04 00 00 00 ff ff 00 00  MZ..............
b8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00  ........@.......
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00  ................
0e 1f ba 0e 00 b4 09 cd 21 b8 01 4c cd 21 54 68  ........!..L.!Th
69 73 20 70 72 6f 67 72 61 6d 20 63 61 6e 6e 6f  is program canno
74 20 62 65 20 72 75 6e 20 69 6e 20 44 4f 53 20  t be run in DOS
6d 6f 64 65 2e 0d 0d 0a 24 00 00 00 00 00 00 00  mode....$.......
2b c4 3f 01 6f a5 51 52 6f a5 51 52 6f a5 51 52  +.?.o.QRo.QRo.QR
db 39 a0 52 62 a5 51 52 db 39 a2 52 fa a5 51 52  .9.Rb.QR.9.R..QR
db 39 a3 52 73 a5 51 52 b2 5a 9a 52 6a a5 51 52  .9.Rs.QR.Z.Rj.QR
6f a5 50 52 30 a5 51 52 8a fc 52 53 79 a5 51 52  o.PR0.QR..RSy.QR
8a fc 54 53 54 a5 51 52 8a fc 55 53 2f a5 51 52  ..TST.QR..US/.QR
9d fc 54 53 6e a5 51 52 9d fc 53 53 6e a5 51 52  ..TSn.QR..SSn.QR
52 69 63 68 6f a5 51 52 00 00 00 00 00 00 00 00  Richo.QR........
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
50 45 00 00 4c 01 05 00 6b e7 07 58 00 00 00 00  PE..L...k..X....

MZ 表示这是 Microsoft 的可移植可执行文件PE)文件格式的 DOS 头部部分。实际的 PE 头部从底部一行的字符 PE 开始。

总结

在本章中,您已经了解了 C++中各种内置类型的初始化和使用方法。您还学会了如何使用转换运算符将变量转换为不同的类型。本章还向您介绍了记录类型,这是一个将在第六章 中进一步扩展的主题。最后,您已经看到了各种指针的示例,这是下一章将更详细地探讨的主题。

第四章:处理内存、数组和指针

C允许您通过指针直接访问内存。这为您提供了很大的灵活性,潜在地可以通过消除一些不必要的数据复制来提高代码的性能。然而,它也提供了额外的错误来源;一些错误对您的应用程序可能是致命的,甚至更糟(是的,比致命更糟!),因为对内存缓冲区的不良使用可能会在您的代码中打开安全漏洞,从而允许恶意软件接管机器。显然,指针是 C的一个重要方面。

在本章中,您将学习如何声明指针并将其初始化为内存位置,如何在堆栈上分配内存和 C自由存储器,并如何使用 C数组。

在 C++中使用内存

C使用与 C 相同的语法来声明指针变量并将它们分配给内存地址,并且具有类似 C 的指针算术。与 C 一样,C还允许您在堆栈上分配内存,因此在堆栈帧被销毁时会自动清理内存,并且动态分配(在 C++自由存储器上),程序员有责任释放内存。本节将涵盖这些概念。

使用 C++指针语法

C++中访问内存的语法很简单。&运算符返回一个对象的地址。这个对象可以是一个变量,一个内置类型或自定义类型的实例,甚至是一个函数(函数指针将在下一章中介绍)。地址被分配给一个类型化的指针变量或一个void*指针。void*指针应该被视为内存地址的存储,因为你不能访问数据,也不能对void*指针进行指针算术(即使用算术运算符操作指针值)。指针变量通常使用类型和*符号声明。例如:

    int i = 42; 
    int *pi = &i;

在这段代码中,变量i是一个整数,编译器和链接器将确定分配这个变量的位置。通常,函数中的变量将在堆栈帧上,如后面的部分所述。在运行时,堆栈将被创建(基本上将分配一块内存),并且在堆栈内存中为变量i保留空间。然后,程序将一个值(42)放入该内存中。接下来,为变量i分配的内存的地址放入变量pi中。上述代码的内存使用情况如下图所示:

指针保存了一个值为0x007ef8c(注意最低字节存储在内存中的最低字节;这是针对 x86 机器的)。内存位置0x007ef8c的值为0x0000002a,即 42 的值,即变量i的值。由于pi也是一个变量,它也占用内存空间,在这种情况下,编译器将指针放在内存中的较低位置,在这种情况下,这两个变量不是连续的。

像这样在堆栈上分配的变量,您不应该假设变量分配在内存中的位置,也不应该假设它们与其他变量的位置有关。

这段代码假设是 32 位操作系统,因此指针pi占用 32 位并包含 32 位地址。如果操作系统是 64 位,则指针将是 64 位宽(但整数可能仍然是 32 位)。在本书中,我们将使用 32 位指针,因为 32 位地址比 64 位地址少打字。

类型化指针使用*符号声明,我们将其称为int*指针,因为指针指向保存int的内存。在声明指针时,约定是将*放在变量名旁边,而不是放在类型旁边。这种语法强调了指向的类型int。但是,如果您在单个语句中声明多个变量,则重要使用此语法:

    int *pi, i;

很明显,第一个变量是int*指针,第二个是int。以下则不太清楚:

    int* pi, i;

你可能会理解这意味着两个变量的类型都是int*但事实并非如此,因为这声明了一个指针和一个int。如果你想声明两个指针,那么对每个变量应用*

    int *p1, *p2;

最好是将这两个指针分开声明。

当你对指针应用sizeof运算符时,你将得到指针的大小,而不是它指向的内容。因此,在 x86 机器上,sizeof(int*)将返回 4;在 x64 机器上,它将返回 8。这是一个重要的观察,特别是当我们在后面的部分讨论 C++内置数组时。

要访问指针指向的数据,你必须使用*运算符对其进行解引用

    int i = 42; 
    int *pi = &i; 
    int j = *pi;

在赋值的右侧使用解引用指针可以访问指针指向的值,所以j被初始化为 42。与指针的声明相比,*符号也被使用,但意义不同。

解引用运算符不仅可以读取内存位置的数据。只要指针没有限制(使用const关键字;见后文),你也可以解引用指针来写入内存位置:

    int i = 42; 
    cout << i << endl; 
    int *pi { &i }; 
    *pi = 99; 
    cout << i << endl;

在这段代码中,指针pi指向变量i在内存中的位置(在这种情况下,使用大括号语法)。对解引用指针进行赋值将值分配给指针指向的位置。结果是在最后一行,变量i的值将是 99 而不是 42。

使用空指针

指针可以指向计算机中安装的内存的任何位置,通过解引用指针进行赋值意味着你可能会覆盖操作系统使用的敏感内存,或者(通过直接内存访问)写入计算机硬件使用的内存。然而,操作系统通常会给可执行文件分配一个特定的内存范围,它可以访问,尝试访问超出此范围的内存将导致操作系统内存访问违规。

因此,你几乎总是应该使用&运算符或从操作系统函数调用中获取指针值。你不应该给指针一个绝对地址。唯一的例外是 C++对于无效内存地址的常量nullptr

    int *pi = nullptr; 
    // code 
    int i = 42; 
    pi = &i; 
    // code 
    if (nullptr != pi) cout << *pi << endl;

这段代码将指针pi初始化为nullptr。稍后在代码中,指针被初始化为整数变量的地址。代码中稍后使用了指针,但是不是立即调用它,而是首先检查指针以确保它已经被初始化为非空值。编译器将检查你是否即将使用一个未初始化的变量,但如果你正在编写库代码,编译器将不知道你的代码的调用者是否正确使用指针。

常量nullptr的类型不是整数,而是std::nullptr_t。所有指针类型都可以隐式转换为此类型,因此nullptr可以用于初始化所有指针类型的变量。

内存类型

一般来说,你可以将内存视为四种类型之一:

  • 静态或全局

  • 字符串池

  • 自动或堆栈

  • 自由存储

当你在全局级别声明一个变量,或者在函数中将变量声明为static时,编译器将确保变量分配的内存具有与应用程序相同的生命周期--变量在应用程序启动时创建,在应用程序结束时删除。

当您使用字符串常量时,数据实际上也是全局变量,但存储在可执行文件的不同部分。对于 Windows 可执行文件,字符串常量存储在可执行文件的.rdata PE/COFF 部分中。文件的.rdata部分用于只读初始化数据,因此您无法更改数据。Visual C++允许您更进一步,并为您提供了字符串池的选项。考虑这个:

    char *p1 { "hello" }; 
    char *p2 { "hello" }; 
    cout << hex; 
    cout << reinterpret_cast<int>(p1) << endl; 
    cout << reinterpret_cast<int>(p2) << endl;

在这段代码中,两个指针被初始化为字符串常量hello的地址。在接下来的两行中,每个指针的地址都打印在控制台上。由于<<运算符对于char*将变量视为指向字符串的指针,它将打印字符串而不是指针的地址。为了解决这个问题,我们调用reinterpret_cast运算符将指针转换为整数并打印整数的值。

如果您在命令行使用 Visual C编译器编译代码,您将看到打印出两个不同的地址。这两个地址都在.rdata部分,并且都是只读的。如果您使用/GF开关编译此代码以启用字符串池(这是 Visual C项目的默认设置),编译器将看到两个字符串常量是相同的,并且只会在.rdata部分存储一个副本,因此此代码的结果将是在控制台上打印两次相同的地址。

在这段代码中,两个变量p1p2是自动变量,也就是说,它们是在当前函数的堆栈上创建的。当调用函数时,为函数分配一块内存,其中包含为函数传递的参数和调用函数的代码的返回地址,以及在函数中声明的自动变量的空间。当函数结束时,堆栈帧被销毁。

函数的调用约定决定了是调用函数还是被调用函数负责这样做。在 Visual C++中,默认的是__cdecl调用约定,这意味着调用函数清理堆栈。__stdcall调用约定被 Windows 操作系统函数使用,并且堆栈清理由被调用函数执行。更多细节将在下一章中给出。

自动变量只在函数执行期间存在,这样变量的地址只在函数内部有意义。在本章的后面,您将看到如何创建数据数组。作为自动变量分配的数组是在编译时确定的固定大小的堆栈上分配的。对于大数组,可能会超出堆栈的大小,特别是在递归调用的函数中。在 Windows 上,默认堆栈大小为 1 MB,在 x86 Linux 上为 2 MB。Visual C++允许您使用/F编译器开关(或/STACK链接器开关)指定更大的堆栈。gcc 编译器允许您使用--stack开关更改默认堆栈大小。

最后一种类型的内存是在自由存储器或有时称为上创建的动态内存。这是使用内存的最灵活的方式。顾名思义,您在运行时分配大小确定的内存。自由存储器的实现取决于 C++的实现,但您应该将自由存储器视为具有与应用程序相同的生命周期,因此从自由存储器分配的内存应该至少持续与应用程序一样长的时间。

然而,这里存在潜在的危险,特别是对于长期运行的应用程序。从自由存储器分配的所有内存都应在使用完毕后返回到自由存储器,以便自由存储器管理器可以重用内存。如果不适当地返回内存,那么自由存储管理器可能会耗尽内存,这将促使它向操作系统请求更多内存,因此,应用程序的内存使用量将随时间增长,导致由于内存分页而引起性能问题。

指针算术

指针指向内存,指针的类型决定了可以通过指针访问的数据的类型。因此,int*指针将指向内存中的整数,并且您可以通过解引用指针(*)来获取整数。如果指针允许(未标记为const),则可以通过指针算术更改其值。例如,您可以增加或减少指针。内存地址的值取决于指针的类型。由于类型化指针指向类型,任何指针算术都将以该类型的size为单位更改指针。

如果您增加一个int*指针,它将指向内存中的下一个整数,内存地址的变化取决于整数的大小。这相当于数组索引,其中诸如v[1]的表达式意味着您应该从v中的第一项的内存位置开始,然后在内存中移动一个项,并返回那里的项:

    int v[] { 1, 2, 3, 4, 5 };
    int *pv = v;
    *pv = 11;
    v[1] = 12;
    pv[2] = 13;
    *(pv + 3) = 14;

第一行在堆栈上分配了一个包含五个整数的数组,并将值初始化为 1 到 5。在这个例子中,因为使用了初始化列表,编译器将为所需数量的项创建空间,因此数组的大小没有给出。如果在括号之间给出数组的大小,那么初始化列表中的项数不能超过数组大小。如果列表中的项数较少,则数组中的其余项将被初始化为默认值(通常为零)。

此代码中的下一行获取数组中第一项的指针。这一行很重要:数组名称被视为指向数组中第一项的指针。接下来的几行以各种方式更改数组项。其中第一行(*pv)通过解引用指针并赋值来更改数组中的第一项。第二行(v[1])使用数组索引为数组中的第二项赋值。第三行(pv[2])使用索引,但这次使用指针,并为数组中的第三个值赋值。最后一个例子(*(pv + 3))使用指针算术来确定数组中第四项的地址(请记住,第一项的索引为 0),然后解引用指针来为该项赋值。在这些操作之后,数组包含值{ 11, 12, 13, 14, 5 },内存布局如下所示:

如果您有一个包含值的内存缓冲区(在本例中,通过数组分配),并且想要将每个值乘以 3,可以使用指针算术来实现:

    int v[] { 1, 2, 3, 4, 5 }; 
    int *pv = v; 
    for (int i = 0; i < 5; ++i) 
    { 
        *pv++ *= 3; 
    }

循环语句很复杂,您需要参考第二章中给出的运算符优先级,理解语言特性。后缀递增运算符具有最高的优先级,其次是解引用运算符(*),最后是*=运算符的优先级最低,因此这些运算符按照这个顺序运行:++**=。后缀运算符返回递增之前的值,因此尽管指针被递增到内存中的下一个项目,表达式使用的是递增之前的地址。然后对这个地址进行解引用,由赋值运算符赋予值乘以 3 的值。这说明了指针和数组名称之间的一个重要区别;您可以递增指针,但不能递增数组:

    pv += 1; // can do this 
    v += 1; // error

当然,您可以在数组名称和指针上都使用索引(使用[])。

使用数组

顾名思义,C内置数组是零个或多个相同类型的数据项。在 C中,使用方括号声明数组和访问数组元素:

    int squares[4]; 
    for (int i = 0; i < 4; ++i)  
    { 
        squares[i] = i * i; 
    }

squares变量是一个整数数组。第一行为四个整数分配了足够的内存,然后for循环初始化了前四个平方的内存。编译器从堆栈中分配的内存是连续的,数组中的项目是顺序的,因此squares[3]的内存位置是从squares[2]sizeof(int)开始的。由于数组是在堆栈上创建的,数组的大小是对编译器的一条指令;这不是动态分配,因此大小必须是一个常量。

这里存在潜在的问题:数组的大小被提到两次,一次在声明中,然后在for循环中再次提到。如果使用两个不同的值,那么您可能会初始化太少的项目,或者可能会访问数组之外的内存。范围for语法允许您访问数组中的每个项目;编译器可以确定数组的大小,并将在范围for循环中使用它。在下面的代码中,有一个故意的错误,显示了数组大小的问题:

    int squares[5]; 
    for (int i = 0; i < 4; ++i)  
    { 
        squares[i] = i * i; 
    } 
    for(int i : squares) 
    { 
        cout << i << endl; 
    }

数组的大小和第一个for循环的范围不一致,因此最后一个项目将不会被初始化。然而,范围for循环将循环遍历所有五个项目,因此将打印出最后一个值的一些随机值。如果使用相同的代码,但将squares数组声明为三个项目呢?这取决于您使用的编译器以及您是否正在编译调试版本,但显然您将写入数组分配之外的内存。

有一些方法可以缓解这些问题。第一个方法在早期的章节中已经提到过:声明一个数组大小的常量,并在代码需要知道数组大小时使用它:

    constexpr int sq_size = 4; 
    int squares[sq_size]; 
    for (int i = 0; i < sq_size; ++i) 
    { 
        squares[i] = i * i; 
    }

数组声明必须有一个常量作为大小,并且通过使用sq_size常量变量来管理。

您可能还想计算已分配数组的大小。sizeof运算符,当应用于数组时,返回整个数组的字节大小,因此您可以通过将这个值除以单个项目的大小来确定数组的大小:

    int squares[4]; 
    for (int i = 0; i < sizeof(squares)/sizeof(squares[0]); ++i) 
    { 
        squares[i] = i * i; 
    }

这是更安全的代码,但显然很冗长。C 运行时库包含一个名为_countof的宏,用于执行这个计算。

函数参数

正如所示,数组会自动转换为适当的指针类型,如果你将数组传递给一个函数,或者从一个函数返回它。这种衰变为愚蠢的指针意味着其他代码不能假设数组的大小。指针可能指向在函数确定内存生命周期的堆栈上分配的内存,或者指向程序的内存生命周期的全局变量,或者可能指向由程序员确定内存的动态分配的内存。在指针声明中没有任何信息表明内存的类型或谁负责释放内存。在愚蠢的指针中也没有任何关于指针指向多少内存的信息。当你使用指针编写代码时,你必须严格遵守它们的使用方式。

函数可以有一个数组参数,但这意味着的远不及它表面所示的那么多:

    // there are four tires on each car 
    bool safe_car(double tire_pressures[4]);

这个函数将检查数组的每个成员是否具有在允许的最小和最大值之间的值。汽车上一次使用四个轮胎,所以这个函数应该被调用以传递一个包含四个值的数组。问题在于,尽管看起来编译器应该检查传递给函数的数组是否是适当的大小,但它并没有。你可以这样调用这个函数:

    double car[4] = get_car_tire_pressures(); 
    if (!safe_car(car)) cout << "take off the road!" << endl; 
    double truck[8] = get_truck_tire_pressures(); 
    if (!safe_car(truck)) cout << "take off the road!" << endl;

当然,开发人员应该明显地意识到卡车不是汽车,因此这个开发人员不应该编写这段代码,但编译语言的通常优势是编译器会为你执行一些合理性检查。在数组参数的情况下,它不会。

原因是数组被传递为指针,因此尽管参数看起来是一个内置数组,但你不能使用你习惯使用的数组功能,比如范围for。事实上,如果safe_car函数调用sizeof(tire_pressures),它将得到一个双指针的大小,而不是 16,即四个int数组的字节大小。

数组参数的衰变为指针特性意味着函数只有在你明确告诉它大小时才会知道数组参数的大小。你可以使用一对空的方括号来表示应该传递一个数组,但实际上它只是一个指针:

    bool safe_car(double tire_pressures[], int size);

这里的函数有一个指示数组大小的参数。前面的函数与声明第一个参数为指针完全相同。以下不是函数的重载;它是相同的函数:

    bool safe_car(double *tire_pressures, int size);

重要的一点是,当你把一个数组传递给一个函数时,数组的第一个维度会被视为一个指针。到目前为止,数组一直是单维的,但它们可能有多个维度。

多维数组

数组可以是多维的,要添加另一个维度,需要添加另一组方括号:

    int two[2]; 
    int four_by_three[4][3];

第一个例子创建了一个包含两个整数的数组,第二个例子创建了一个包含 12 个整数的二维数组,排列成四行三列。当然,是任意的,并且将二维数组视为传统的电子表格表格,但这有助于可视化数据在内存中的排列方式。

注意每个维度周围都有方括号。在这方面,C与其他语言不同,所以int x[10,10]的声明将被 C编译器报告为错误。

初始化多维数组涉及一对大括号和按照将用于初始化维度的顺序排列的数据:

    int four_by_three[4][3] { 11,12,13,21,22,23,31,32,33,41,42,43 };

在这个例子中,具有最高数字的值反映了最左边的索引,较低的数字反映了右边的索引(在这两种情况下,比实际索引多一个)。显然,你可以将这个分成几行,并使用空格来将值分组在一起,以使其更易读。你也可以使用嵌套的大括号。例如:

    int four_by_three[4][3] = { {11,12,13}, {21,22,23}, 
                                {31,32,33}, {41,42,43} };

如果你从左到右读取维度,你可以读取初始化值进入更深层次的嵌套。有四行,所以在外部大括号内有四组嵌套的大括号。有三列,所以在嵌套的大括号内有三个初始化值。

嵌套的大括号不仅仅是为了格式化你的 C++代码的方便,因为如果你提供了一对空的大括号,编译器将使用默认值:

    int four_by_three[4][3] = { {11,12,13}, {}, {31,32,33}, {41,42,43} };

这里,第二行的项目被初始化为 0。

当你增加维度时,原则仍然适用:增加最右边维度的嵌套:

    int four_by_three_by_two[4][3][2]  
       = { { {111,112}, {121,122}, {131,132} }, 
           { {211,212}, {221,222}, {231,232} }, 
           { {311,312}, {321,322}, {331,332} }, 
           { {411,412}, {421,422}, {431,432} }  
         };

这是四行三列的成对数组(当维度增加时,可以看出术语在很大程度上是任意的)。

你可以使用相同的语法访问项目:

    cout << four_by_three_by_two[3][2][0] << endl; // prints 431

就内存布局而言,编译器以以下方式解释语法。第一个索引确定了从数组开始处的偏移量,每次偏移六个整数(3 * 2),第二个索引指示了在这六个整数“块”内的偏移量,每次偏移两个整数,第三个索引是以单个整数为单位的偏移量。因此[3][2][0]是从开始处*(3 * 6) + (2 * 2) + 0 = 22*个整数的偏移量,将第一个整数视为索引零。

多维数组被视为数组的数组,因此每个“行”的类型是int[3][2],我们从声明中知道有四个这样的行。

将多维数组传递给函数

你可以将多维数组传递给一个函数:

    // pass the torque of the wheel nuts of all wheels 
    bool safe_torques(double nut_torques[4][5]);

这样编译后,你可以将参数作为一个 4x5 的数组访问,假设这辆车有四个轮子,每个轮子上有五个螺母。

如前所述,当你传递一个数组时,第一个维度将被视为指针,所以虽然你可以将一个 4x5 的数组传递给这个函数,你也可以传递一个 2x5 的数组,编译器不会抱怨。然而,如果你传递一个 4x3 的数组(也就是说,第二个维度与函数中声明的不同),编译器将发出一个数组不兼容的错误。参数可能更准确地描述为double row[][5]。由于第一个维度的大小不可用,函数应该声明该维度的大小:

    bool safe_torques(double nut_torques[][5], int num_wheels);

这说明nut_torques是一个或多个“行”,每个行有五个项目。由于数组没有提供有关它有多少行的信息,你应该提供它。另一种声明方式是:

    bool safe_torques(double (*nut_torques)[5], int num_wheels);

这里括号很重要,如果你省略它们并使用double *nut_torques[5],那么*将指的是数组中的类型,也就是说,编译器将把nut_torques视为一个double*指针的五个元素数组。我们之前已经看到了这样一个数组的例子:

    void main(int argc, char *argv[]);

argv参数是一个char*指针数组。你也可以将argv参数声明为char**,它具有相同的含义。

一般来说,如果你打算将数组传递给一个函数,最好使用自定义类型,或者使用 C++数组类型。

使用多维数组的范围for循环比第一眼看上去更复杂,并且需要在本章后面的部分中解释的引用的使用。

使用字符数组

字符串将在第九章 使用字符串中更详细地介绍,但值得指出的是,C 字符串是字符数组,并且通过指针变量访问。这意味着如果你想操作字符串,你必须操作指针指向的内存,而不是操作指针本身。

比较字符串

以下分配了两个字符串缓冲区,并调用strcpy_s函数来用相同的字符串初始化每个缓冲区:

    char p1[6]; 
    strcpy_s(p1, 6, "hello"); 
    char p2[6]; 
    strcpy_s(p2, 6, p1); 
    bool b = (p1 == p2);

strcpy_c函数将从最后一个参数中给定的指针(直到终止的NUL)复制字符,到第一个参数中给定的缓冲区中,该缓冲区的最大大小在第二个参数中给出。这两个指针在最后一行进行比较,这将返回一个false值。问题在于比较函数比较的是指针的值,而不是指针指向的内容。这两个缓冲区具有相同的字符串,但指针不同,因此b将是false

比较字符串的正确方法是逐个字符比较数据以查看它们是否相等。C 运行时提供了strcmp,它逐个字符比较两个字符串缓冲区,并且std::string类定义了一个名为compare的函数,也将执行这样的比较;但是,要注意这些函数返回的值:

    string s1("string"); 
    string s2("string"); 
    int result = s1.compare(s2);

返回值不是bool类型,表示两个字符串是否相同;它是一个int。这些比较函数进行词典比较,如果参数(在这个代码中是s2)在词典上大于操作数(s1),则返回一个负值,如果操作数大于参数,则返回一个正数。如果两个字符串相同,函数返回 0。记住,bool对于值为 0 是false,对于非零值是true。标准库为std::string提供了==运算符的重载,因此可以安全地编写这样的代码:

    if (s1 == s2) 
    { 
        cout << "strings are the same" << endl; 
    }

操作员将比较两个变量中包含的字符串。

防止缓冲区溢出

用于操作字符串的 C 运行时库以允许缓冲区溢出而臭名昭著。例如,strcpy函数将一个字符串复制到另一个字符串,并且您可以通过<cstring>头文件访问它,该头文件由<iostream>头文件包含。您可能会尝试编写类似这样的代码:

    char pHello[5];          // enough space for 5 characters 
    strcpy(pHello, "hello");

问题在于strcpy将复制所有字符直到包括终止的NULL字符,因此您将把六个字符复制到只有个空间的数组中。您可能会从用户输入中获取一个字符串(比如,从网页上的文本框),并认为您分配的数组足够大,但是恶意用户可能会提供一个故意大于缓冲区的过长字符串,以便覆盖程序的其他部分。这种缓冲区溢出导致许多程序遭受黑客控制服务器的攻击,以至于 C 字符串函数都已被更安全的版本所取代。实际上,如果您尝试键入上述代码,您会发现strcpy是可用的,但是 Visual C++编译器会发出错误:

error C4996: 'strcpy': This function or variable may be unsafe. 
Consider using strcpy_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS. See online help for details.

如果您有使用strcpy的现有代码,并且需要使该代码编译,可以在<cstring>之前定义该符号:

    #define _CRT_SECURE_NO_WARNINGS 
    #include <iostream>

防止这个问题的一个初始尝试是调用strncpy,它将复制特定数量的字符:

    char pHello[5];             // enough space for 5 characters 
    strncpy(pHello, "hello", 5);

该函数将复制最多五个字符,然后停止。问题在于要复制的字符串有五个字符,因此结果将没有NULL终止。此函数的更安全版本具有一个参数,您可以使用该参数指定目标缓冲区的大小:

    size_t size = sizeof(pHello)/sizeof(pHello[0]); 
    strncpy_s(pHello, size, "hello", 5);

在运行时,这仍然会导致问题。您告诉函数缓冲区大小为五个字符,它将确定这不足以容纳您要求它复制的六个字符。与其允许程序悄悄继续并且缓冲区溢出导致问题,更安全的字符串函数将调用一个名为约束处理程序的函数,其默认版本将关闭程序,理由是缓冲区溢出意味着程序已受到威胁。

C 运行时库字符串函数最初是为了返回函数的结果,现在更安全的版本返回一个错误值。strncpy_s函数也可以被告知截断复制而不是调用约束处理程序:

    strncpy_s(pHello, size, "hello", _TRUNCATE);

C++的string类可以保护你免受这些问题的困扰。

在 C++中使用指针

在 C中,指针显然非常重要,但与任何强大的功能一样,都存在问题和危险,因此值得指出一些主要问题。指针指向内存中的单个位置,指针的类型表示应该如何解释内存位置。你最多可以假设的是在内存中该位置的字节数是指针类型的大小。就是这样。这意味着指针本质上是不安全的。然而,在 C中,它们是使你的进程内的代码快速访问大量数据的最快方式。

访问超出边界

当你分配一个缓冲区,无论是在堆栈上还是在自由存储器上,并且你得到一个指针时,很少有东西能阻止你访问你没有分配的内存--无论是在缓冲区的位置之前还是之后。这意味着当你使用指针算术或数组的索引访问时,你要仔细检查你是否将要访问超出边界的数据。有时错误可能并不立即显而易见:

    int arr[] { 1, 2, 3, 4 }; 
    for (int i = 0; i < 4; ++i)  
    { 
        arr[i] += arr[i + 1]; // oops, what happens when i == 3? 
    }

当你使用索引时,你必须不断提醒自己数组是从零开始索引的,所以最高的索引是数组大小减 1。

指向释放内存的指针

这适用于在堆栈上分配的内存和动态分配的内存。以下是一个糟糕编写的函数,它在函数中返回了一个在堆栈上分配的字符串:

    char *get() 
    { 
        char c[] { "hello" };
        return c;
    }

前面的代码分配了一个六个字符的缓冲区,然后用字符串字面量hello的五个字符和NULL终止字符对其进行初始化。问题在于一旦函数完成,堆栈帧就会被拆除,以便内存可以被重新使用,指针将指向可能被其他东西使用的内存。这个错误是由糟糕的编程引起的,但在这个例子中可能并不像这么明显。如果函数使用了几个指针并执行了指针赋值,你可能不会立即注意到你已经返回了一个指向堆栈分配对象的指针。最好的做法就是简单地不要从函数中返回原始指针,但如果你确实想使用这种编程风格,确保内存缓冲区是通过参数传递的(所以函数不拥有缓冲区),或者是动态分配的,并且你正在将所有权传递给调用者。

这引出了另一个问题。如果你在指针上调用delete,然后在你的代码中稍后尝试访问指针,你将访问可能被其他变量使用的内存。为了缓解这个问题,你可以养成在删除指针时将指针赋值为null_ptr并在使用指针之前检查null_ptr的习惯。或者,你可以使用智能指针对象来代替。智能指针将在第六章中介绍,

转换指针

你可以使用类型化指针,也可以使用void*指针。类型化指针将访问内存,就好像它是指定的类型(当你在类中使用继承时,这会产生有趣的后果,但这将留到第六章,和第七章,面向对象编程简介)。因此,如果你将指针转换为不同的类型并对其进行解引用,内存将被视为包含转换类型。这很少有意义。void*指针不能被解引用,因此你永远无法通过void*指针访问数据,要访问数据,你必须转换指针。void*指针类型的整个原因是它可以指向任何东西。通常情况下,只有当类型对该函数无关紧要时,才应该使用void*指针。例如,C 中的malloc函数返回一个void*指针,因为该函数仅分配内存;它不关心该内存将用于什么目的。

常量指针

指针可以声明为const,这取决于你在哪里应用它,这意味着指针指向的内存是只读的,或者指针的值是只读的:

    char c[] { "hello" }; // c can be used as a pointer 
    *c = 'H';             // OK, can write thru the pointer 
    const char *ptc {c};  // pointer to constant 
    cout << ptc << endl;  // OK, can read the memory pointed to 
    *ptc =  'Y';          // cannot write to the memory 
    char *const cp {c};   // constant pointer 
    *cp = 'y';            // can write thru the pointer 
    cp++;                 // cannot point to anything else

在这里,ptc是一个指向常量char的指针,也就是说,尽管你可以更改ptc指向的内容,并且可以读取它指向的内容,但你不能使用它来更改内存。另一方面,cp是一个常量指针,这意味着你既可以读取也可以写入指针指向的内存,但你不能更改它指向的位置。通常将const char*指针传递给函数,因为函数不知道字符串在哪里分配,或者缓冲区的大小(调用者可能传递一个无法更改的文字)。请注意,没有const*运算符,因此char const*被视为const char*,即指向常量缓冲区的指针。

你可以使用转换使指针变为常量,更改它,或者移除它。以下是对const关键字进行了一些相当无意义的更改,以证明这一点:

    char c[] { "hello" }; 
    char *const cp1 { c }; // cannot point to any other memory 
    *cp1 = 'H';            // can change the memory 
    const char *ptc = const_cast<const char*>(cp1); 
    ptc++;                 // change where the pointer points to 
    char *const cp2 = const_cast<char *const>(ptc); 
    *cp2 = 'a';            // now points to Hallo

指针cp1cp2可以用于更改它们指向的内存,但一旦分配,它们都不能指向其他内存。第一个const_cast去除了指向其他内存的const属性,但不能用于更改内存,ptc。第二个const_cast去除了ptcconst属性,以便可以通过指针更改内存,cp2

更改指向的类型

static_cast运算符用于进行编译时检查的转换,而不是运行时检查,这意味着指针必须是相关的。void*指针可以转换为任何指针,因此以下内容可以编译并且是有意义的:

    int *pi = static_cast<int*>(malloc(sizeof(int))); 
    *pi = 42; 
    cout << *pi << endl; 
    free(pi);

C 中的malloc函数返回一个void*指针,因此你必须转换它才能使用内存。(当然,C++的new运算符消除了这种转换的需要。)内置类型不足够“相关”,无法使用static_cast在指针类型之间进行转换,因此你不能使用static_castint*指针转换为char*指针,即使intchar都是整数类型。对于通过继承相关的自定义类型,你可以使用static_cast进行指针转换,但没有运行时检查来验证转换是否正确。要进行带有运行时检查的转换,应该使用dynamic_cast,更多细节将在第六章,和第七章,面向对象编程简介中给出。

reinterpret_cast运算符是转换运算符中最灵活、最危险的,因为它将在没有任何类型检查的情况下在任何指针类型之间进行转换。这是不安全的。例如,以下代码使用文字初始化宽字符数组。数组wc将有六个字符,hello后跟NULLwcout对象将wchar_t*指针解释为wchar_t字符串中第一个字符的指针,因此插入wc将打印字符串(直到NUL为止)。要获取实际的内存位置,您必须将指针转换为整数:

    wchar_t wc[] { L"hello" }; 
    wcout << wc << " is stored in memory at "; 
    wcout << hex; 
    wcout << reinterpret_cast<int>(wc) << endl;

同样,如果将wchar_t插入wcout对象中,它将打印字符,而不是数值。因此,要打印出各个字符的代码,我们需要将指针转换为合适的整数指针。此代码假定shortwchar_t大小相同:

    wcout << "The characters are:" << endl; 
    short* ps = reinterpret_cast<short*>(wc); 
    do  
    {  
        wcout << *ps << endl;  
    } while (*ps++);

在代码中分配内存

C++定义了两个运算符newdelete,它们从自由存储区分配内存并将内存释放回自由存储区。

分配单个对象

new运算符与类型一起用于分配内存,并将返回指向该内存的类型化指针:

    int *p = new int; // allocate memory for one int

new运算符将为创建的每个对象调用自定义类型的默认构造函数(如第六章中所述,)。内置类型没有构造函数,因此将发生类型初始化,这通常会将对象初始化为零(在此示例中为零整数)。

一般来说,您不应该在没有明确初始化的情况下使用为内置类型分配的内存。实际上,在 Visual C++中,new运算符的调试版本将将内存初始化为每个字节的值0xcd,作为调试器中的视觉提醒,表明您尚未初始化内存。对于自定义类型,将分配的内存初始化留给类型的作者。

重要的是,当您使用完内存后,将其返回到自由存储区,以便分配器可以重用它。您可以通过调用delete运算符来执行此操作:

    delete p;

当您删除指针时,将调用对象的析构函数。对于内置类型,这不会有任何作用。在删除指针后,将指针初始化为nullptr是一个良好的做法,如果您使用在使用指针之前检查指针的值的约定,这将保护您免受使用已删除指针的伤害。C++标准规定,如果删除具有nullptr值的指针,delete运算符将不起作用。

C++允许您以两种方式在调用new运算符时初始化值:

    int *p1 = new int (42); 
    int *p2 = new int {42};

对于自定义类型,new运算符将调用类型的构造函数;对于内置类型,最终结果是相同的,并且通过将项目初始化为提供的值来执行。您还可以使用初始化列表语法,如前面代码中的第二行所示。重要的是要注意,初始化是指向的内存,而不是指针变量。

分配对象数组

您还可以使用new运算符在动态内存中创建对象数组。您可以通过提供要创建的项目数的一对方括号来执行此操作。以下代码为两个整数分配内存:

    int *p = new int[2]; 
    p[0] = 1; 
    *(p + 1) = 2; 
    for (int i = 0; i < 2; ++i) cout << p[i] << endl; 
    delete [] p;

该运算符返回分配的类型的指针,您可以使用指针算术或数组索引来访问内存。您不能在new语句中初始化内存;您必须在创建缓冲区后执行此操作。当您使用new为多个对象创建缓冲区时,必须使用适当版本的delete运算符:[]用于指示删除多个项目,并将调用每个对象的析构函数。重要的是,您始终要使用与用于创建指针的new版本相适应的正确版本的delete

自定义类型可以为单个对象定义自己的运算符new和运算符delete,以及为对象数组定义运算符new[]和运算符delete[]。自定义类型的作者可以使用这些来为其对象使用自定义内存分配方案。

处理失败的分配

如果new运算符无法为对象分配内存,它将引发std::bad_alloc异常,并且返回的指针将为nullptr。异常在第十章中有所涵盖,诊断和调试,因此此处仅给出语法的简要概述。在生产代码中,重要的是检查内存分配失败。以下代码显示了如何保护分配,以便捕获std::bad_alloc异常并处理它:

    // VERY_BIG_NUMER is a constant defined elsewhere 
    int *pi; 
    try 
    { 
        pi = new int[VERY_BIG_NUMBER]; 
        // other code 
    } 
    catch(const std::bad_alloc& e)  
    {  
        cout << "cannot allocate" << endl;  
        return; 
    } 
    // use pointer 
    delete [] pi;

如果try块中的任何代码引发异常控制,则将其传递到catch子句,忽略尚未执行的任何其他代码。catch子句检查异常对象的类型,如果是正确的类型(在本例中是分配故障),则创建对该对象的引用,并将控制传递到catch块,异常引用的范围是此块。在此示例中,代码仅打印错误,但您将使用它来采取措施以确保内存分配失败不会影响后续代码。

使用其他版本的 new 运算符

此外,自定义类型可以定义放置运算符new,允许您为自定义new函数提供一个或多个参数。放置new的语法是通过括号提供放置字段。

C++标准库版本的new运算符提供了一个可以将常量std::nothrow作为放置字段的版本。如果分配失败,此版本不会抛出异常,而是只能从返回指针的值来评估失败:

    int *pi = new (std::nothrow) int [VERY_BIG_NUMBER]; 
    if (nullptr == pi)  
    { 
        cout << "cannot allocate" << endl; 
    } 
    else 
    { 
        // use pointer 
        delete [] pi; 
    }

在类型之前使用括号用于传递放置字段。如果在类型之后使用括号,这些将为成功分配内存的对象初始化一个值。

内存寿命

new分配的内存将保持有效,直到调用delete。这意味着您可能拥有寿命很长的内存,并且代码可能在代码中的各种函数之间传递。考虑以下代码:

    int *p1 = new int(42); 
    int *p2 = do_something(p1); 
    delete p1; 
    p1 = nullptr; 
    // what about p2?

此代码创建一个指针并初始化其指向的内存,然后将指针传递给一个函数,该函数本身返回一个指针。由于不再需要p1指针,因此将其删除并分配为nullptr,以便不能再次使用。这段代码看起来不错,但问题是您如何处理函数返回的指针?想象一下,该函数只是操作指针指向的数据:

    int *do_something(int *p) 
    { 
        *p *= 10; 
        return p; 
    }

实际上,调用do_something会创建指针的副本,但不会创建指向的内容的副本。这意味着当删除p1指针时,它指向的内存将不再可用,因此指针p2指向无效内存。

可以使用一种称为资源获取即初始化RAII)的机制来解决这个问题,这意味着使用 C对象的特性来管理资源。C中的 RAII 需要类,特别是复制构造函数和析构函数。智能指针类可用于管理指针,以便在复制时也复制其指向的内存。析构函数是在对象超出范围时自动调用的函数,因此智能指针可以使用它来释放内存。智能指针和析构函数将在第六章中进行介绍,

Windows SDK 和指针

从函数返回指针具有其固有的危险:内存的责任被传递给调用者,调用者必须确保内存得到适当释放,否则可能导致内存泄漏和相应的性能损失。在本节中,我们将探讨 Windows 软件开发工具包SDK)提供对内存缓冲区的访问以及学习 C++中使用的一些技术。

首先,值得指出的是,Windows SDK 中返回字符串或具有字符串参数的任何函数都将有两个版本。带有A后缀的版本表示该函数使用 ANSI 字符串,而W版本将使用宽字符字符串。对于本讨论,使用 ANSI 函数更容易。

GetCommandLineA函数具有以下原型(考虑 Windows SDK 的typedef):

    char * __stdcall GetCommandLine();

所有 Windows 函数都被定义为使用__stdcall调用约定。通常,您会看到WINAPItypedef用于__stdcall调用约定。

该函数可以这样调用:

    //#include <windows.h>
    cout << GetCommandLineA() << endl;

请注意,我们没有努力释放返回的缓冲区。原因是指针指向的内存存在于进程的生命周期中,因此您不应释放它。实际上,如果您释放它,您该如何做呢?您无法保证该函数是使用相同的编译器或您正在使用的相同库编写的,因此您无法使用 C++的delete运算符或 C 的free函数。

当函数返回缓冲区时,重要的是查阅文档以查看是谁分配了缓冲区,以及谁应该释放它。

另一个例子是GetEnvironmentStringsA

    char * __stdcall GetEnvironmentStrings();

这也返回一个指向缓冲区的指针,但这次文档清楚地指出在使用缓冲区后应释放它。SDK 提供了一个名为FreeEnvironmentStrings的函数来执行此操作。缓冲区中包含形式为name=value的每个环境变量的一个字符串,并且每个字符串都以NUL字符终止。缓冲区中的最后一个字符串只是一个NUL字符,也就是说,缓冲区的末尾有两个NUL字符。这些函数可以这样使用:

    char *pBuf = GetEnvironmentStringsA(); 
    if (nullptr != pBuf) 
    { 
        char *pVar = pBuf; 
        while (*pVar) 
        { 
            cout << pVar << endl; 
            pVar += strlen(pVar) + 1; 
        } 

        FreeEnvironmentStringsA(pBuf); 
    }

strlen函数是 C 运行时库的一部分,它返回字符串的长度。您不需要知道GetEnvironmentStrings函数如何分配缓冲区,因为FreeEnvironmentStrings将调用正确的释放代码。

有些情况下,开发人员有责任分配缓冲区。Windows SDK 提供了一个名为GetEnvironmentVariable的函数,用于返回命名环境变量的值。当您调用此函数时,您不知道环境变量是否设置,或者如果设置了,其值有多大,因此这意味着您很可能需要分配一些内存。该函数的原型是:

    unsigned long __stdcall GetEnvironmentVariableA(const char *lpName,   
        char *lpBuffer, unsigned long nSize);

有两个参数是指向 C 字符串的指针。这里有一个问题,char*指针可以用于将字符串传递给函数,也可以用于传递字符串返回的缓冲区。您如何知道char*指针的预期用途是什么?

完整的参数声明给了你一个线索。lpName指针被标记为const,所以函数不会改变它指向的字符串;这意味着它是一个输入参数。这个参数用于传递你想要获取的环境变量的名称。另一个参数只是一个char*指针,所以它可以用来向函数传递一个字符串输入,或者输出,或者两者兼而有之。知道如何使用这个参数的唯一方法是阅读文档。在这种情况下,它是一个输出参数;如果变量存在,函数将返回lpBuffer中的环境变量的值,如果变量不存在,函数将保持缓冲区不变,并返回值 0。你有责任以任何你认为合适的方式分配这个缓冲区,并且你要在最后一个参数nSize中传递这个缓冲区的大小。

函数的返回值有两个目的。它用于指示发生了错误(只有一个值,0,这意味着你必须调用GetLastError函数来获取错误),它还用于提供有关缓冲区lpBuffer的信息。如果函数成功,则返回值是复制到缓冲区中的字符数,不包括NULL终止字符。然而,如果函数确定缓冲区太小(它从nSize参数知道缓冲区的大小)无法容纳环境变量值,将不会发生复制,并且函数将返回缓冲区所需的大小,即环境变量中的字符数,包括NULL终止符。

调用这个函数的常见方法是先用一个大小为零的缓冲区调用它,然后再使用返回值来分配一个缓冲区,然后再次调用它:

    unsigned long size = GetEnvironmentVariableA("PATH", nullptr, 0); 
    if (0 == size)  
    { 
        cout << "variable does not exist " << endl; 
    } 
    else 
    { 
        char *val = new char[size]; 
        if (GetEnvironmentVariableA("PATH", val, size) != 0) 
        { 
            cout << "PATH = ";
            cout << val << endl; 
        } 
        delete [] val; 
    }

一般来说,和所有的库一样,你必须阅读文档来确定参数的使用方式。Windows 文档会告诉你指针参数是输入、输出还是输入/输出。它还会告诉你谁拥有内存,以及你是否有责任分配和/或释放内存。

每当你看到一个函数的指针参数时,一定要特别注意检查文档,了解指针的用途以及内存是如何管理的。

内存和 C++标准库

C标准库提供了各种类来允许你操作对象的集合。这些类被称为标准模板库STL),它们提供了一种标准的方式来向集合对象插入项目,并且访问项目并遍历整个集合(称为迭代器)。STL 定义了作为队列、堆栈或具有随机访问的向量的集合类。这些类将在第八章中深入讨论,使用标准库容器,所以在本节中我们将仅限于讨论两个行为类似于 C内置数组的类。

标准库数组

C++标准库提供了两个容器,通过索引器可以随机访问数据。这两个容器还允许你访问底层内存,并且由于它们保证将项目顺序存储并且在内存中是连续的,所以当你需要提供一个指向缓冲区的指针时,它们可以被使用。这两种类型都是模板,这意味着你可以用它们来保存内置类型和自定义类型。这两个集合类分别是arrayvector

使用基于堆栈的数组类

array类在<array>头文件中定义。该类允许您在堆栈上创建固定大小的数组,并且与内置数组一样,它们不能在运行时收缩或扩展。由于它们是在堆栈上分配的,因此它们不需要在运行时调用内存分配器,但显然,它们应该比堆栈帧大小小。这意味着array是小型项目的良好选择。array的大小必须在编译时知道,并作为模板参数传递:

    array<int, 4> arr { 1, 2, 3, 4 };

在这段代码中,尖括号(<>)中的第一个模板参数是数组中每个项目的类型,第二个参数是项目的数量。这段代码使用初始化列表初始化数组,但请注意,您仍然必须在模板中提供数组的大小。这个对象将像内置数组(或者确实,任何标准库容器)一样使用范围for

    for (int i : arr) cout << i << endl;

原因是array实现了所需的beginend函数,这是这种语法所必需的。您还可以使用索引来访问项目:

    for (int i = 0; i < arr.size(); ++i) cout << arr[i] << endl;

size函数将返回数组的大小,方括号索引器将随机访问数组的成员。您可以访问数组范围之外的内存,因此对于先前定义的具有四个成员的数组,您可以访问arr[10]。这可能会导致运行时出现意外行为,甚至某种内存故障。为了防范这种情况,该类提供了一个at函数,它将执行范围检查,如果索引超出范围,该类将抛出 C++异常out_of_range

使用array对象的主要优势在于,您可以在编译时检查是否无意中将对象作为愚蠢的指针传递给函数。考虑这个函数:

    void use_ten_ints(int*);

在运行时,函数不知道传递给它的缓冲区的大小,在这种情况下,文档说您必须传递一个具有 10 个int类型变量的缓冲区,但是,正如我们所见,C++允许使用内置数组作为指针:

    int arr1[] { 1, 2, 3, 4 }; 
    use_ten_ints(arr1); // oops will read past the end of the buffer

没有编译器检查,也没有运行时检查来捕捉此错误。array类不会允许发生这样的错误,因为没有自动转换为愚蠢的指针:

    array<int, 4> arr2 { 1, 2, 3, 4 };  
    use_ten_ints(arr2); // will not compile

如果您坚持要获得一个愚蠢的指针,您可以这样做,并保证以顺序存储的方式访问数据作为一个连续的内存块:

    use_ten_ints(&arr2[0]);    // compiles, but on your head be it 
    use_ten_ints(arr2.data()); // ditto

该类不仅是内置数组的包装器,还提供了一些额外的功能。例如:

    array<int, 4> arr3; 
    arr3.fill(42);   // put 42 in each item 
    arr2.swap(arr3); // swap items in arr2 with items in arr3

使用动态分配的向量类

标准库还在<vector>头文件中提供了vector类。同样,这个类是一个模板,所以你可以用它来处理内置和自定义类型。然而,与array不同,内存是动态分配的,这意味着vector可以在运行时扩展或收缩。项目是连续存储的,因此您可以通过调用data函数或访问第一个项目的地址来访问底层缓冲区(为了支持调整集合的大小,缓冲区可能会改变,因此这样的指针应该只是暂时使用)。当然,与array一样,没有自动转换为愚蠢的指针。vector类提供了带方括号语法的索引随机访问和at函数的范围检查。该类还实现了允许容器与标准库函数和范围for一起使用的方法。

vector类比array类更灵活,因为您可以插入项目,并移动项目,但这会带来一些开销。因为类的实例在运行时动态分配内存,使用分配器的成本,以及在初始化和销毁时的一些额外开销(当vector对象超出范围时)。vector类的对象也比它所持有的数据占用更多的内存。因此,它不适用于少量项目(当array是更好的选择时)。

引用

引用是对象的别名。也就是说,它是对象的另一个名称,因此通过引用访问对象与通过对象的变量名访问对象是相同的。引用使用&符号在引用名称上声明,并且它的初始化和访问方式与变量完全相同:

    int i = 42; 
    int *pi = &i;  // pointer to an integer 
    int& ri1 = i;  // reference to a variable 
    i = 99;        // change the integer thru the variable 
    *pi = 101;     // change the integer thru the pointer 
    ri1 = -1;      // change the integer thru the reference 
    int& ri2 {i};  // another reference to the variable 
    int j = 1000; 
    pi = &j;       // point to another integer

在这段代码中,声明并初始化了一个变量,然后初始化了一个指针以指向这个数据,并且初始化了一个引用作为变量的别名。引用ri1是使用赋值运算符初始化的,而引用ri2是使用初始化器列表语法初始化的。

指针和引用有两个不同的含义。引用不是初始化为变量的值,变量的数据;它是变量名的别名。

无论变量在哪里被使用,都可以使用引用;对引用所做的任何操作实际上都等同于对变量执行相同的操作。指针指向数据,因此您可以通过取消引用指针来更改数据,同样,您也可以使指针指向任何数据,并通过取消引用指针来更改该数据(这在前面代码的最后两行中有所说明)。您可以为一个变量有几个别名,并且每个别名在声明时必须初始化为该变量。一旦声明,就不能使引用引用不同的对象。

以下代码将无法编译:

    int& r1;           // error, must refer to a variable 
    int& r2 = nullptr; // error, must refer to a variable

由于引用是另一个变量的别名,因此它不能存在而不被初始化为一个变量。同样,您不能将其初始化为除变量名以外的任何东西,因此没有空引用的概念。

一旦初始化,引用只是一个变量的别名。实际上,当您将引用用作任何运算符的操作数时,操作是在变量上执行的。

    int x = 1, y = 2;  
    int& rx = x; // declaration, means rx is an alias for x 
    rx = y;      // assignment, changes value of x to the value of y

在这段代码中,rx是变量x的别名,因此最后一行的赋值只是将x赋值为y的值:赋值是在别名变量上执行的。此外,如果您取引用的地址,将返回引用的变量的地址。虽然您可以有一个数组的引用,但不能有一个引用的数组。

常量引用

到目前为止使用的引用允许您更改它是别名的变量,因此它具有左值语义。还有const左值引用,也就是说,引用一个对象,您可以读取,但不能写入。

const指针一样,您可以使用const关键字在左值引用上声明const引用。这基本上使引用只读:您可以访问变量的数据以读取它,但不能更改它。

    int i = 42; 
    const int& ri = i; 
    ri = 99;           // error!

返回引用

有时,一个对象将被传递给一个函数,函数的语义是应该返回该对象。一个例子是与流对象一起使用的<<运算符。对此运算符的调用是链接的:

    cout << "The value is " << 42;

这实际上是一系列对名为operator<<的函数的调用,其中一个接受const char*指针,另一个接受int参数。这些函数还有一个ostream参数,用于指定将要使用的流对象。然而,如果这只是一个ostream参数,那么意味着会创建参数的副本,并且插入操作将在副本上执行。流对象通常使用缓冲,因此对流对象的副本进行更改可能不会产生预期的效果。此外,为了启用插入操作符的链接,插入函数将返回作为参数传递的流对象。意图是通过多个函数调用传递相同的流对象。如果这样的函数返回一个对象,那么它将是一个副本,这不仅意味着一系列插入将涉及大量的副本,这些副本也将是临时的,因此对流的任何更改(例如,std::hex等操作符)将不会持久存在。为了解决这些问题,使用引用。这样的函数的典型原型是:

    ostream& operator<<(ostream& _Ostr, int _val);

显然,你必须小心返回引用,因为你必须确保对象的生命周期与引用一样长。这个operator<<函数将返回第一个参数传递的引用,但在下面的代码中,引用将返回给一个自动变量:

    string& hello() 
    { 
        string str ("hello"); 
        return str; // don't do this! 
    }   // str no longer exists at this point

在前面的代码中,string对象的生存期只有函数的生存期那么长,因此这个函数返回的引用将指向一个不存在的对象。当然,你可以返回一个指向函数中声明的static变量的引用。

从函数返回引用是一种常见的习惯用法,但每当你考虑这样做时,一定要确保别名变量的生命周期不是函数的作用域。

临时对象和引用

左值引用必须引用一个变量,但是当涉及到堆栈上声明的const引用时,C++有一些奇怪的规则。如果引用是const,编译器将延长临时对象的生命周期,使其与引用的生命周期相同。例如,如果你使用初始化列表语法,编译器将创建一个临时对象:

    const int& cri { 42 };

在这段代码中,编译器将创建一个临时的int并将其初始化为一个值,然后将其别名到cri引用(这个引用是const很重要)。只要引用在作用域内,临时对象就可以通过引用使用。这可能看起来有点奇怪,但考虑在这个函数中使用一个const引用:

    void use_string(const string& csr);

你可以用一个string变量、一个明确转换为string的变量或一个string字面量来调用这个函数:

    string str { "hello" }; 
    use_string(str);      // a std::string object 
    const char *cstr = "hello"; 
    use_string(cstr);     // a C string can be converted to a std::string 
    use_string("hello");  // a literal can be converted to a std::string

在大多数情况下,你不会想要一个内置类型的const引用,但对于自定义类型,其中复制会有开销,这是一个优势,正如你在这里看到的,编译器将退回到创建临时对象的方式。

右值引用

C11 定义了一种新类型的引用,即右值引用。在 C11 之前,代码(比如赋值操作符)无法知道传递给它的右值是临时对象还是其他。如果这样的函数被传递一个对象的引用,那么函数必须小心不要改变引用,因为这会影响它所引用的对象。如果引用是指向临时对象的,那么函数可以对临时对象做任何喜欢的事情,因为对象在函数完成后不会存在。C++11 允许你专门为临时对象编写代码,因此在赋值的情况下,临时对象的操作符可以将数据从临时对象移动到被赋值的对象中。相比之下,如果引用不是指向临时对象,那么数据将被复制。如果数据很大,那么这将阻止潜在的昂贵的分配和复制。这实现了所谓的移动语义

考虑这个相当牵强的代码:

    string global{ "global" }; 

    string& get_global() 
    { 
        return global; 
    } 

    string& get_static() 
    { 
        static string str { "static" }; 
        return str; 
    } 

    string get_temp() 
    { 
        return "temp"; 
    }

这三个函数返回一个string对象。在前两种情况下,string的生命周期为整个程序,因此可以返回一个引用。在最后一个函数中,函数返回一个字符串字面值,因此会构造一个临时的string对象。这三个函数都可以用来提供一个string值。例如:

    cout << get_global() << endl; 
    cout << get_static() << endl; 
    cout << get_temp() << endl;

所有三个函数都可以提供一个可以用来赋值给string对象的字符串。重要的是,前两个函数返回一个已经存在的对象,而第三个函数返回一个临时对象,但这些对象可以被同样使用。

如果这些函数返回对一个大对象的访问,你可能不希望将对象传递给另一个函数,因此,在大多数情况下,你会希望将这些函数返回的对象作为引用传递。例如:

    void use_string(string& rs);

引用参数可以避免对字符串进行另一个复制。然而,这只是故事的一半。use_string函数可以操作字符串。例如,下面的函数从参数创建一个新的string,但用下划线替换了字母 a、b 和 o(表示没有这些字母的单词中的空格,模拟没有 A、B 和 O 血型捐赠的生活)。一个简单的实现看起来像这样:

    void use_string(string& rs) 
    { 
        string s { rs }; 
        for (size_t i = 0; i < s.length(); ++i) 
        { 
            if ('a' == s[i] || 'b' == s[i] || 'o' == s[i])  
            s[i] = '_'; 
        } 
        cout << s << endl; 
    }

string对象有一个索引运算符([]),因此可以将其视为一个字符数组,既可以读取字符的值,也可以为字符位置分配值。string的大小通过length函数获得,该函数返回一个unsigned inttypedefsize_t)。由于参数是一个引用,这意味着对string的任何更改都将反映在传递给函数的string中。这段代码的意图是保持其他变量不变,因此首先对参数进行复制。然后在副本上,代码遍历所有字符,将abo字符更改为下划线,然后打印出结果。

这段代码显然有一个复制开销--从引用rs创建string s;但如果我们想要将get_globalget_static返回的字符串传递给这个函数,这是必要的,否则更改将会影响实际的全局和static变量。

然而,从get_temp返回的临时string是另一种情况。这个临时对象只存在到调用get_temp的语句结束。因此,可以对变量进行更改,知道这不会影响其他东西。这意味着可以使用移动语义:

    void use_string(string&& s) 
    { 
        for (size_t i = 0; i < s.length(); ++i) 
        { 
            if ('a' == s[i] || 'b' == s[i] || 'o' == s[i]) s[i] = '_'; 
        } 
        cout << s << endl; 
    }

这里只有两个变化。第一个是参数被标识为一个右值引用,使用&&后缀来表示类型。另一个变化是对引用所指向的对象进行更改,因为我们知道它是一个临时对象,这些更改将被丢弃,因此不会影响其他变量。请注意,现在有两个函数,重载了相同的名称:一个带有左值引用,一个带有右值引用。当调用这个函数时,编译器将根据传递给它的参数调用正确的函数:

    use_string(get_global()); // string&  version 
    use_string(get_static()); // string&  version 
    use_string(get_temp());   // string&& version 
    use_string("C string");   // string&& version 
    string str{"C++ string"}; 
    use_string(str);          // string&  version

请记住,get_globalget_static返回将在程序的生命周期内存在的对象的引用,因此编译器选择了接受左值引用的use_string版本。更改是在函数内的临时变量上进行的,这会产生复制开销。get_temp返回一个临时对象,因此编译器调用接受右值引用的use_string的重载。这个函数改变了引用所指的对象,但这并不重要,因为该对象不会持续到行末的分号之后。对于使用类似 C 的字符串文字调用use_string也是一样的:编译器会创建一个临时的string对象,并调用带有右值引用参数的重载。在这段代码的最后一个例子中,一个 C++ string对象在堆栈上创建,并传递给use_string。编译器看到这个对象是一个左值,并且可能会被改变,因此调用了接受左值引用的重载,这种重载的实现方式只会改变函数中的临时局部变量。

这个例子表明,C++编译器会检测参数是否是临时对象,并调用带有右值引用的重载。通常,这种功能用于编写复制构造函数(用于从现有实例创建新自定义类型的特殊函数)和赋值运算符,以便这些函数可以实现左值引用重载以从参数复制数据,并实现右值引用重载以将数据从临时对象移动到新对象。其他用途是编写仅移动的自定义类型,它们使用无法复制的资源,例如文件句柄。

范围for和引用

作为引用的一个例子,值得看看 C++11 中的范围for功能。下面的代码非常简单;数组squares用 0 到 4 的平方初始化:

    constexpr int size = 4; 
    int squares[size]; 

    for (int i = 0; i < size; ++i) 
    { 
        squares[i] = i * i; 
    }

编译器知道数组的大小,因此可以使用范围for来打印数组中的值。在下面的例子中,每次迭代,局部变量j都是数组中项目的副本。作为副本,这意味着你可以读取该值,但对变量所做的任何更改都不会反映到数组中。因此,下面的代码按预期工作;它打印出数组的内容。

    for (int j : squares) 
    { 
        cout << J << endl; 
    }

如果要更改数组中的值,那么必须访问实际的值,而不是副本。在范围for中实现这一点的方法是使用引用作为循环变量:

    for (int& k : squares) 
    { 
        k *= 2; 
    }

现在,在每次迭代中,k变量都是数组中实际成员的别名,因此对k变量所做的任何操作实际上都是在数组成员上执行的。在这个例子中,squares数组的每个成员都乘以 2。你不能使用int*作为k的类型,因为编译器会看到数组中的项目类型是int,并将其作为范围for中的循环变量。由于引用是变量的别名,编译器将允许引用作为循环变量,并且由于引用是别名,你可以使用它来更改实际的数组成员。

对于多维数组,范围for变得很有趣。例如,在下面的例子中,声明了一个二维数组,并尝试使用auto变量来使用嵌套循环:

    int arr[2][3] { { 2, 3, 4 }, { 5, 6, 7} };   
    for (auto row : arr) 
    { 
        for (auto col : row) // will not compile
        { 
            cout << col << " " << endl; 
        } 
    }

由于二维数组是数组的数组(每一行都是一个一维数组),意图是在外部循环中获取每一行,然后在内部循环中访问每一行中的每个项目。这种方法存在一些问题,但是最直接的问题是这段代码无法编译。

编译器将抱怨内部循环,说它找不到类型int*beginend函数。原因是范围for使用迭代器对象,对于数组,它使用 C++标准库函数beginend来创建这些对象。编译器将从外部范围for中的arr数组中看到每个项目都是一个int[3]数组,因此在外部for循环中,循环变量将是每个元素的副本,在这种情况下是一个int[3]数组。你不能像这样复制数组,所以编译器将提供指向第一个元素的指针,一个int*,并且这在内部for循环中使用。

编译器将尝试为int*获取迭代器,但这是不可能的,因为int*不包含有关它指向多少项的信息。对于int[3](以及所有大小的数组)定义了beginend的版本,但对于int*没有定义。

简单的更改使得这段代码可以编译。只需将row变量转换为引用即可:

    for (auto& row : arr) 
    { 
        for (auto col : row) 
        { 
            cout << col << " " << endl; 
        } 
    }

引用参数表示int[3]数组使用别名,当然,别名与元素相同。使用auto隐藏了实际发生的丑陋。内部循环变量当然是int,因为这是数组中项目的类型。外部循环变量实际上是int (&)[3]。也就是说,它是一个int[3]的引用(括号用于指示它引用一个int[3],而不是一个int&数组)。

在实践中使用指针

一个常见的要求是拥有一个可以在运行时是任意大小并且可以增长和缩小的集合。C标准库提供了各种类来允许你做到这一点,将在第八章中描述,使用标准库容器。以下示例说明了这些标准集合是如何实现的一些原则。一般来说,你应该使用 C标准库类而不是实现你自己的。此外,标准库类将代码封装在一个类中,因为我们还没有涵盖类,所以下面的代码将使用潜在可能被错误调用的函数。因此,你应该把这个例子只是一个例子代码。链表是一种常见的数据结构。这些通常用于队列,其中项目的顺序很重要。例如,先进先出队列,其中任务按照它们插入队列的顺序执行。在这个例子中,每个任务都表示为一个包含任务描述和指向要执行的下一个任务的指针的结构。

如果下一个任务的指针是nullptr,那么这意味着当前任务是列表中的最后一个任务:

    struct task 
    { 
        task* pNext; 
        string description; 
    };

回想一下上一章,你可以通过实例使用点运算符访问结构的成员:

    task item; 
    item.descrription = "do something";

在这种情况下,编译器将创建一个用字符串字面量do something初始化的string对象,并将其分配给名为item的实例的description成员。你也可以使用new运算符在自由存储区创建一个task

    task* pTask = new task; 
    // use the object 
    delete pTask;

在这种情况下,必须通过指针访问对象的成员,C++提供了->运算符来给你这种访问:

    task* pTask = new task; 
    pTask->descrription = "do something"; 
    // use the object 
    delete pTask;

这里description成员被赋予了字符串。请注意,由于task是一个结构,没有访问限制,这在类中是很重要的,并在第六章中描述,

创建项目

C:\Beginning_C++下创建一个名为Chapter_04的新文件夹。启动 Visual C++,创建一个 C源文件并将其保存到刚创建的文件夹中,命名为tasks.cpp。添加一个简单的没有参数的main函数,并使用 C流提供输入和输出支持:

    #include <iostream> 
    #include <string> 
    using namespace std; 

    int main() 
    {
    }

main函数上面,添加一个代表列表中任务的结构的定义:

    using namespace std;  
 struct task { task* pNext; string description; };

这有两个成员。对象的核心是description项。在我们的例子中,执行任务将涉及将description项打印到控制台。在实际项目中,您很可能会有许多与任务相关的数据项,甚至可能有成员函数来执行任务,但我们还没有涵盖成员函数;这是第六章 的主题。

链表的连接是另一个成员,pNext。请注意,在声明pNext成员时,task结构尚未完全定义。这不是问题,因为pNext是一个指针。您不能有一个未定义或部分定义类型的数据成员,因为编译器不知道为其分配多少内存。您可以有一个指向部分定义类型的指针成员,因为指针成员的大小不受其指向的内容的影响。

如果我们知道列表中的第一个链接,那么我们可以访问整个列表,在我们的例子中,这将是一个全局变量。在构造列表时,构造函数需要知道列表的末尾,以便它们可以将新的链接附加到列表上。同样,为了方便起见,我们将使其成为一个全局变量。在task结构的定义之后添加以下指针:

 task* pHead = nullptr; task* pCurrent = nullptr;  
    int main() 
    {
    }

就目前而言,代码什么也没做,但这是一个很好的机会来编译文件,以测试是否有拼写错误:

cl /EHsc tasks.cpp

向列表中添加任务对象

为了提供代码,下一步是向任务列表添加一个新任务。这需要创建一个新的task对象并适当地初始化它,然后通过改变列表中的最后一个链接来将其添加到列表中,使其指向新的链接。

main函数之前,添加以下函数:

    void queue_task(const string& name) 
    { 
        ...
    }

参数是const引用,因为我们不会改变参数,也不希望产生额外的复制开销。这个函数必须做的第一件事是创建一个新的链接,所以添加以下行:

    void queue_task(const string& name) 
    { 
 task* pTask = new task; pTask->description = name; pTask->pNext = nullptr; 
    }

第一行在自由存储器上创建一个新的链接,接下来的行初始化它。这不一定是初始化这样一个对象的最佳方式,更好的机制,构造函数,将在第六章 中介绍。请注意,pNext项被初始化为nullptr;这表示该链接将位于列表的末尾。

该函数的最后部分将链接添加到列表中,即使链接成为列表中的最后一个。但是,如果列表为空,这意味着该链接也是列表中的第一个链接。代码必须执行这两个操作。在函数的末尾添加以下代码:

    if (nullptr == pHead) 
    { 
        pHead = pTask; 
        pCurrent = pTask; 
    } 
    else 
    { 
        pCurrent->pNext = pTask; 
        pCurrent = pTask; 
    }

第一行检查列表是否为空。如果pHeadnullptr,这意味着没有其他链接,因此当前链接是第一个链接,因此pHeadpCurrent都初始化为新链接指针。如果列表中存在现有链接,则必须将链接添加到最后一个链接,因此在else子句中,第一行使最后一个链接指向新链接,第二行使用新链接指针初始化pCurrent,使新链接成为列表中任何新插入的最后一个链接。

通过在main函数中调用此函数将项目添加到列表中。在这个例子中,我们将排队进行粘贴壁纸的任务。这涉及到去除旧壁纸,填补墙壁上的任何孔洞,调整墙壁大小(用稀释的糊状物涂抹墙壁,使墙壁变得粘性),然后将粘贴的壁纸贴到墙上。您必须按照这个顺序完成这些任务,不能改变顺序,因此这些任务非常适合使用链表。在main函数中添加以下行:

    queue_task("remove old wallpaper"); 
    queue_task("fill holes"); 
    queue_task("size walls"); 
    queue_task("hang new wallpaper");

在最后一行之后,列表已创建。pHead变量指向列表中的第一项,您可以通过简单地从一个链接到下一个链接来访问列表中的任何其他项。

您可以编译代码,但没有输出。更糟糕的是,代码的当前状态存在内存泄漏。程序没有代码来deletenew运算符在自由存储器上创建的task对象所占用的内存。

删除任务列表

遍历列表很简单,只需按照pNext指针从一个链接到下一个链接。在执行此操作之前,让我们先修复上一节中引入的内存泄漏。在main函数上面,添加以下函数:

    bool remove_head() 
    { 
        if (nullptr == pHead) return false; 
        task* pTask = pHead; 
        pHead = pHead->pNext; 
        delete pTask; 
        return (pHead != nullptr); 
    }

此函数将删除列表开头的链接,并确保pHead指针指向下一个链接,这将成为列表的新开头。该函数返回一个bool值,指示列表中是否还有其他链接。如果此函数返回false,则表示整个列表已被删除。

第一行检查此函数是否已使用空列表调用。一旦我们确信列表至少有一个链接,我们就会创建此指针的临时副本。原因是打算删除第一项并使pHead指向下一项,为此我们必须反向执行这些步骤:使pHead指向下一项,然后删除pHead先前指向的项。

要删除整个列表,需要通过链接进行迭代,可以使用while循环进行。在remove_head函数下面,添加以下内容:

    void destroy_list() 
    { 
        while (remove_head()); 
    }

要删除整个列表,并解决内存泄漏问题,将以下行添加到主函数的底部

 destroy_list(); 
    }

现在可以编译代码并运行它。但是,您将看不到任何输出,因为所有代码只是创建一个列表,然后删除它。

遍历任务列表

下一步是从第一个链接开始迭代列表,直到通过每个pNext指针到达列表的末尾。对于访问的每个链接,应执行任务。首先编写一个执行任务的函数,该函数通过打印任务的描述然后返回指向下一个任务的指针来执行任务。在main函数上面,添加以下代码:

    task *execute_task(const task* pTask) 
    { 
        if (nullptr == pTask) return nullptr; 
        cout << "executing " << pTask->description << endl; 
        return pTask->pNext; 
    }

这里的参数标记为const,因为我们不会改变指向的task对象。这告诉编译器,如果代码尝试更改对象,则会出现问题。第一行检查确保函数不是用空指针调用的。如果是,那么接下来的行将取消引用无效的指针并导致内存访问故障。最后一行返回指向下一个链接的指针(对于列表中的最后一个链接可能是nullptr),以便可以在循环中调用该函数。在此函数之后,添加以下内容以迭代整个列表:

    void execute_all() 
    { 
        task* pTask = pHead; 
        while (pTask != nullptr) 
        { 
            pTask = execute_task(pTask); 
        } 
    }

此代码从开头pHead开始,并在列表中的每个链接上调用execute_task,直到函数返回nullptr。在main函数的末尾添加对此函数的调用:

 execute_all(); 
        destroy_list(); 
    }

现在可以编译并运行代码。结果将是:

    executing remove old wallpaper
executing fill holes
 executing size walls executing hang new wallpaper

插入项目

链表的一个优点是,您可以通过仅分配一个新项目并更改适当的指针来将项目插入列表,并使其指向列表中的下一个项目。与分配task对象的数组相比,这与在中间插入新项目要简单得多;如果要在中间插入新项目,您必须为旧项目和新项目分配足够大的新数组,然后将旧项目复制到新数组中,在正确的位置复制新项目。

壁纸任务列表的问题在于房间有一些涂过油漆的木制品,正如任何装饰者所知,最好在贴壁纸之前先涂油漆,通常是在涂墙之前。我们需要在填补任何孔之后和调整墙壁之前插入一个新任务。此外,在进行任何装饰之前,您应该先覆盖房间中的任何家具,因此需要在开头添加一个新任务。

第一步是找到我们想要放置新任务的位置来粉刷木制品。我们将寻找我们要插入的任务之前的任务。在main之前添加以下内容:

    task *find_task(const string& name) 
    { 
        task* pTask = pHead; 

        while (nullptr != pTask) 
        { 
            if (name == pTask->description) return pTask; 
            pTask = pTask->pNext; 
        }  
        return nullptr; 
    }

这段代码搜索整个列表,查找与参数匹配的description链接。这是通过循环执行的,循环使用string比较运算符,如果找到所需的链接,则返回指向该链接的指针。如果比较失败,循环将循环变量初始化为下一个链接的地址,如果此地址为nullptr,则意味着列表中没有所需的任务。

在主函数中创建列表后,添加以下代码来搜索fill holes任务:

    queue_task("hang new wallpaper"); 

 // oops, forgot to paint 
    woodworktask* pTask = find_task("fill holes"); if (nullptr != pTask) { // insert new item after pTask } 
    execute_all();

如果find_task函数返回有效指针,那么我们可以在此处添加一个项目。

此函数允许您在传递给它的列表中的任何项目后添加新项目,如果传递nullptr,它将在开头添加新项目。它被称为insert_after,但显然,如果你传递nullptr,它也意味着在开头之前插入。在main函数上面添加以下内容:

    void insert_after(task* pTask, const string& name) 
    { 
        task* pNewTask = new task; 
        pNewTask->description = name; 
        if (nullptr != pTask) 
        { 
            pNewTask->pNext = pTask->pNext; 
            pTask->pNext = pNewTask; 
        } 
    }

第二个参数是const引用,因为我们不会改变string,但第一个参数不是const指针,因为我们将改变它指向的对象。此函数创建一个新的task对象,并将description成员初始化为新的任务名称。然后检查传递给函数的task指针是否为空。如果不是,则可以在列表中指定链接之后插入新项目。为此,新链接pNext成员被初始化为列表中的下一个项目,并且前一个链接的pNext成员被初始化为新链接的地址。

当函数传递nullptr作为要插入的项目时,如何在开头插入一个项目?添加以下else子句。

    void insert_after(task* pTask, const string& name) 
    { 
        task* pNewTask = new task; 
        pNewTask->description = name; 
        if (nullptr != pTask) 
        { 
            pNewTask->pNext = pTask->pNext; 
            pTask->pNext = pNewTask; 
        } 
        else { pNewTask->pNext = pHead; pHead = pNewTask; } 
    }

在这里,我们使新项目的pNext成员指向旧列表的开头,然后将pHead更改为指向新项目。

现在,在main函数中,您可以添加一个调用来插入一个新任务来粉刷木制品,因为我们还忘记指出最好在用防尘布覆盖所有家具后装饰房间,所以在列表中首先添加一个任务来做到这一点:

    task* pTask = find_task("fill holes"); 
    if (nullptr != pTask) 
    { 
 insert_after(pTask, "paint woodwork"); 
    } 
 insert_after(nullptr, "cover furniture");

现在可以编译代码了。运行代码时,您应该按照所需的顺序执行任务:

 executing cover furniture executing remove old wallpaper
executing fill holes
executing paint woodwork
executing size walls
executing hang new wallpaper 

总结

可以说使用 C的主要原因之一是你可以使用指针直接访问内存。这是大多数其他语言的程序员无法做到的特性。这意味着作为 C程序员,你是一种特殊类型的程序员:一个被信任处理内存的人。在本章中,你已经看到如何获取和使用指针,以及指针的不当使用如何使你的代码出现严重错误的一些示例。

在下一章中,我们将介绍包括另一种指针类型的描述:函数指针。如果你被信任使用数据指针和函数指针,那么你真的是一种特殊类型的程序员。

第五章:使用函数

函数是 C的基本基础设施;代码包含在函数中,要执行该代码,你必须调用一个函数。C在定义和调用函数的方式上非常灵活:你可以定义具有固定数量参数或可变数量参数的函数;你可以编写通用代码,以便相同的代码可以用于不同的类型;甚至可以编写具有可变数量类型的通用代码。

定义 C++函数

在最基本的层面上,函数有参数,有用于操作参数的代码,并返回一个值。C提供了几种方法来确定这三个方面。在接下来的部分中,我们将从声明的左边到右边来介绍 C函数的这些部分。函数也可以是模板的,但这将留到以后的部分。

声明和定义函数

函数必须被定义一次,但通过重载,你可以有许多具有相同名称但参数不同的函数。使用函数的代码必须能够访问函数的名称,因此它需要访问函数的定义(例如,函数在源文件中较早地定义)或函数的声明(也称为函数原型)。编译器使用原型来对调用代码进行类型检查,以确保使用正确的类型调用函数。

通常,库被实现为单独的编译库文件,并且库函数的原型在头文件中提供,以便许多源文件可以通过包含这些头文件来使用这些函数。然而,如果你知道函数的名称、参数和返回类型,你可以在你的文件中自己输入原型。无论你做什么,你只是提供信息给编译器来对调用函数的表达式进行类型检查。链接器负责在库中定位函数,并将代码复制到可执行文件中,或者设置基础设施以从共享库中使用函数。包含库的头文件并不意味着你可以使用该库中的函数,因为在标准 C++中,头文件并不包含包含函数的库的信息。

Visual C++提供了一个名为pragmacomment,它可以与lib选项一起使用,作为向链接器发送链接到特定库的消息。因此,在头文件中使用#pragma comment(lib, "mylib")将告诉链接器链接到mylib.lib。一般来说,最好使用项目管理工具,如nmakeMSBuild,以确保正确的库被链接到项目中。

大部分 C 运行库都是这样实现的:函数被编译为静态库或动态链接库,函数的原型在头文件中提供。你需要在链接器命令行中提供库,并且通常你会包含库的头文件,以便编译器可以使用函数的原型。只要链接器知道库的存在,你就可以在你的代码中输入原型(并将其描述为外部链接,以便编译器知道函数是在其他地方定义的)。这可以避免将一些大文件包含到你的源文件中,这些文件大多数情况下只包含你不会使用的函数的原型。

然而,C标准库的大部分实现在头文件中,这意味着这些文件可能会非常大。你可以通过将这些头文件包含在预编译头文件中来节省编译时间,如第一章中所述,*开始学习 C*。

到目前为止,在本书中,我们只使用了一个源文件,因此所有的函数都是在使用它们的同一个文件中定义的,并且我们在调用函数之前定义了函数,也就是说,函数在调用它的代码上方被定义。只要在调用函数之前定义了函数原型,就不必在使用函数之前定义函数:

    int mult(int, int); 

    int main() 
    { 
        cout << mult(6, 7) << endl; 
        return 0; 
    } 

    int mult(int lhs, int rhs) 
    { 
        return lhs * rhs; 
    }

mult函数在main函数之后定义,但这段代码将会编译,因为在main函数之前给出了原型。这被称为前向声明。原型不必有参数名。这是因为编译器只需要知道参数的类型,而不需要它们的名称。然而,由于参数名应该是自我说明的,通常最好给出参数名,以便你可以看到函数的目的。

指定链接

在前面的例子中,函数在同一个源文件中定义,因此具有内部链接。如果函数在另一个文件中定义,原型将具有外部链接,因此原型必须这样定义:

    extern int mult(int, int);        // defined in another file

extern关键字是你可以添加到函数声明中的许多限定符之一,在前几章中我们已经见过其他的。例如,static限定符可以用于原型,表示函数具有内部链接,名称只能在当前源文件中使用。在前面的例子中,在原型中将函数标记为static是合适的。

    static int mult(int, int);        // defined in this file

你还可以将函数声明为extern "C",这会影响函数名称在目标文件中的存储方式。这对库很重要,不久将会介绍。

内联

如果函数计算的值可以在编译时计算,你可以在声明的左边使用constexpr标记它,以指示编译器可以通过在编译时计算值来优化代码。如果函数值可以在编译时计算,这意味着函数调用中的参数必须在编译时已知,因此它们必须是文字。函数还必须是单行的。如果不满足这些限制,那么编译器可以自由地忽略该限定符。

相关的是inline限定符。这可以放在函数声明的左边,作为对编译器的建议,当其他代码调用函数时,编译器不是插入一个跳转到内存中的函数(和创建一个堆栈帧),而是将实际代码的副本放在调用函数中。同样,编译器可以自由地忽略这个限定符。

确定返回类型

函数可能被编写为运行一个例程而不返回值。如果是这种情况,你必须指定函数返回void。在大多数情况下,函数会返回一个值,即使只是表示函数已经正确完成。没有要求调用函数获取返回值或对其进行任何操作。调用函数可以简单地忽略返回值。

有两种方法可以指定返回类型。第一种方法是在函数名之前给出类型。这是到目前为止大多数示例中使用的方法。第二种方法称为尾返回类型,要求在函数名之前将auto作为返回类型,并使用->语法在参数列表之后给出实际的返回类型:

    inline auto mult(int lhs, int rhs) -> int 
    { 
        return lhs * rhs; 
    }

这个函数非常简单,因此很适合内联。左边的返回类型是auto,意味着实际的返回类型在参数列表之后指定。-> int表示返回类型是int。这种语法与在左边使用int具有相同的效果。当函数是模板化的且返回类型可能不明显时,这种语法很有用。

在这个简单的例子中,你可以完全省略返回类型,只需在函数名的左边使用auto。这种语法意味着编译器将根据实际返回的值推断返回类型。显然,编译器只能从函数体知道返回类型,因此你不能为这种函数提供原型。

最后,如果一个函数根本不返回(例如,如果它进入一个永不结束的循环来轮询某个值),你可以使用 C++11 属性[[noreturn]]标记它。编译器可以使用这个属性来编写更高效的代码,因为它知道不需要提供返回值的代码。

命名函数

一般来说,函数名的规则与变量相同:它们必须以字母或下划线开头,不能包含空格或其他标点符号。遵循自解释代码的一般原则,你应该根据函数的功能来命名函数。有一个例外,那就是用于为运算符提供重载的特殊函数(大部分是标点符号)。这些函数的名称形式为operatorx,其中x是你在代码中将使用的运算符。后面的部分将解释如何使用全局函数实现运算符。

运算符是重载的一个例子。你可以重载任何函数,也就是说,使用相同的名称但提供不同参数类型或不同数量的参数的实现。

函数参数

函数可能没有参数,这种情况下函数定义为一对空括号。函数定义必须在括号之间给出参数的类型和名称。在许多情况下,函数将有固定数量的参数,但你可以编写具有可变数量参数的函数。你还可以为一些参数定义默认值,实际上,提供了一个根据传递给函数的参数数量进行重载的函数。可变参数列表和默认参数将在后面介绍。

指定异常

函数还可以标记是否会抛出异常。关于异常的更多细节将在第十章 诊断和调试中给出,但你需要了解两种语法。

早期版本的 C++允许你以三种方式在函数上使用throw说明符:首先,你可以提供一个逗号分隔的异常类型列表,这些异常可能由函数中的代码抛出;其次,你可以提供一个省略号(...),这意味着函数可能抛出任何异常;第三,你可以提供一个空的括号对,这意味着函数不会抛出异常。语法看起来像这样:

    int calculate(int param) throw(overflow_error) 
    { 
        // do something which potentially may overflow 
    }

throw说明符在 C11 中已经被弃用,主要是因为指示异常类型的能力并不实用。然而,C11 保留了指示不会抛出异常的throw版本,因为它使编译器能够通过提供不处理异常的代码基础设施来优化代码。C++11 使用noexcept说明符保留了这种行为:

    // C++11 style: 
    int increment(int param) noexcept 
    { 
        // check the parameter and handle overflow appropriately 
    }

函数体

确定了返回类型、函数名和参数之后,你需要定义函数的主体。函数的代码必须出现在一对大括号({})之间。如果函数返回一个值,那么函数必须至少有一行(函数中的最后一行)带有return语句。这个语句必须返回适当的类型或者可以隐式转换为函数返回类型的类型。如前所述,如果函数声明为返回auto,那么编译器将推断返回类型。在这种情况下,所有的return语句必须返回相同的类型。

使用函数参数

当调用函数时,编译器会检查函数的所有重载,以找到与调用代码中的参数匹配的函数。如果没有完全匹配,则执行标准和用户定义的类型转换,因此调用代码提供的值可能与参数的类型不同。

默认情况下,参数是按值传递的,并且会进行复制,这意味着参数在函数中被视为局部变量。函数的编写者可以决定通过指针或 C++引用通过引用传递参数。按引用传递意味着调用代码中的变量可以被函数修改,但这可以通过使参数const来控制,这样按引用传递的原因是为了防止进行(可能昂贵的)复制。内置数组始终作为指向数组第一项的指针传递。编译器将在需要时创建临时对象。例如,当参数是const引用时,调用代码传递文字时,将创建临时对象,并且仅可用于函数中的代码:

    void f(const float&); 
    f(1.0);              // OK, temporary float created 
    double d = 2.0; 
    f(d);                // OK, temporary float created

传递初始化列表

如果该列表可以转换为参数的类型,则可以将初始化列表作为参数传递。例如:

    struct point { int x; int y; }; 

    void set_point(point pt); 

    int main() 
    { 
        point p; 
        p.x = 1; p.y = 1; 
        set_point(p); 
        set_point({ 1, 1 });  
        return 0; 
    }

此代码定义了一个具有两个成员的结构。在main函数中,在堆栈上创建了一个point的新实例,并通过直接访问成员对其进行初始化。然后将该实例传递给具有point参数的函数。由于set_point的参数是按值传递的,编译器会在函数的堆栈上创建结构的副本。第二次调用set_point也是如此:编译器将在函数的堆栈上创建一个临时的point对象,并使用初始化列表中的值对其进行初始化。

使用默认参数

有时您有一个或多个参数,其值使用得如此频繁,以至于您希望它们被视为参数的默认值,同时又可以允许调用者在必要时提供不同的值。为此,您在定义的参数列表中提供默认值:

    void log_message(const string& msg, bool clear_screen = false) 
    { 
        if (clear_screen) clear_the_screen(); 
        cout << msg << endl; 
    }

在大多数情况下,预计该函数用于打印单个消息,但偶尔用户可能希望首先清除屏幕(例如,对于第一条消息,或在预定的行数之后)。为了适应函数的这种用法,clear_screen参数被赋予了默认值false,但调用者仍然可以选择传递一个值:

    log_message("first message", true); 
    log_message("second message"); 
    bool user_decision = ask_user(); 
    log_message("third message", user_decision);

请注意,默认值出现在函数定义中,而不是在函数原型中,因此如果log_message函数在头文件中声明,则原型应为:

    extern void log_message(const string& msg, bool clear_screen);

可以具有默认值的参数是最右边的参数。

您可以将具有默认值的每个参数视为函数的单独重载,因此在概念上,log_message函数应该被视为两个函数:

    extern void log_message(const string& msg, bool clear_screen); 
    extern void log_message(const string& msg); // conceptually

如果定义了一个只有const string&参数的log_message函数,那么编译器将不知道是调用该函数还是clear_screen被赋予默认值false的版本。

参数的数量可变

具有默认参数值的函数可以被视为具有用户提供的可变数量的参数,其中您在编译时知道参数的最大数量和它们的值,如果调用者选择不提供值。C++还允许您编写函数,其中对于参数的数量以及传递给函数的值存在较少的确定性。

有三种方法可以具有可变数量的参数:初始化列表,C 风格的可变参数列表和可变模板函数。这三种方法中的后者将在本章后面讨论,一旦涵盖了模板函数。

初始化列表

到目前为止,在这本书中,初始化列表被视为一种 C++11 构造,有点像内置数组。实际上,当你使用大括号的初始化列表语法时,编译器实际上会创建一个模板化的initialize_list类的实例。如果使用初始化列表来初始化另一种类型(例如,初始化一个vector),编译器会使用大括号中给定的值创建一个initialize_list对象,并使用initialize_list迭代器初始化容器对象。这种从大括号初始化列表创建initialize_list对象的能力可以用来给函数传递可变数量的参数,尽管所有参数都必须是相同类型的:

    #include <initializer_list> 

    int sum(initializer_list<int> values) 
    { 
        int sum = 0; 
        for (int i : values) sum += i; 
        return sum; 
    } 

    int main() 
    { 
        cout << sum({}) << endl;                       // 0 
        cout << sum({-6, -5, -4, -3, -2, -1}) << endl; // -21 
        cout << sum({10, 20, 30}) << endl;             // 60 
        return 0; 
    }

sum函数有一个initializer_list<int>的单一参数,它只能用整数列表进行初始化。initializer_list类的函数非常少,因为它只存在于给予大括号列表中的值的访问。重要的是,它实现了一个size函数,返回列表中的项目数,以及beginend函数,返回指向列表中第一个项目和最后一个项目后面位置的指针。这两个函数是为了给列表提供迭代器访问而需要的,并且它使你可以使用范围-for语法来使用对象。

这在 C++标准库中很典型。如果一个容器在内存中以连续的内存块保存数据,那么指针算术可以使用指向第一个项目的指针和指向最后一个项目后面的指针来确定容器中有多少项目。递增第一个指针可以顺序访问每个项目,并且指针算术允许随机访问。所有容器都实现了beginend函数,以便访问容器的迭代器

在这个例子中,main函数三次调用这个函数,每次都使用大括号初始化列表,并且函数将返回列表中项目的总和。

显然,这种技术意味着可变参数列表中的每个项目都必须是相同类型(或者可以转换为指定类型的类型)。如果参数是一个vector,你会得到相同的结果;不同之处在于initializer_list参数需要更少的初始化。

参数列表

C++继承了 C 的参数列表的概念。为了实现这一点,你可以使用省略号语法(...)作为最后一个参数,表示调用者可以提供零个或多个参数。编译器将检查函数的调用方式,并在堆栈上为这些额外的参数分配空间。要访问额外的参数,你的代码必须包含<cstdarg>头文件,其中包含了可以用来从堆栈中提取额外参数的宏。

这是固有的类型不安全,因为编译器无法检查函数在运行时从堆栈中获取的参数是否与调用代码放在堆栈上的参数类型相同。例如,以下是一个求和整数的函数实现:

    int sum(int first, ...) 
    { 
        int sum = 0;    
        va_list args; 
        va_start(args, first); 
        int i = first; 
        while (i != -1) 
        { 
            sum += i; 
            i = va_arg(args, int); 
        } 
        va_end(args); 
        return sum; 
    }

函数的定义必须至少有一个参数,以便宏起作用;在这种情况下,参数被称为first。重要的是,你的代码要保持堆栈处于一致的状态,这是通过va_list类型的变量来实现的。这个变量在函数开始时通过调用va_start宏进行初始化,并在函数结束时通过调用va_end宏将堆栈恢复到其先前的状态。

这个函数中的代码简单地遍历参数列表,并维护一个总和,当参数的值为-1 时循环结束。没有宏可以提供关于堆栈上有多少参数的信息,也没有宏可以提供关于堆栈上参数的类型的指示。你的代码必须假设变量的类型,并在va_arg宏中提供所需的类型。在这个例子中,va_arg被调用,假设堆栈上的每个参数都是int

当所有参数都从堆栈中读取完毕后,代码在返回总和之前调用va_end。函数可以这样调用:

    cout << sum(-1) << endl;                       // 0 
    cout << sum(-6, -5, -4, -3, -2, -1) << endl;   // -20 !!! 
    cout << sum(10, 20, 30, -1) << endl;           // 60

由于-1用于指示列表的结尾,这意味着要对零个参数求和,你必须至少传递一个参数,即-1。此外,第二行显示了如果传递了一系列负数(在这种情况下-1不能是一个参数),那么你会遇到问题。在这个实现中,这个问题可以通过选择另一个标记值来解决。

另一种实现可以放弃使用列表末尾的标记,而是使用第一个必需的参数来给出后续参数的数量:

    int sum(int count, ...) 
    { 
        int sum = 0; 
        va_list args; 
        va_start(args, count); 
        while(count--) 
        { 
            int i = va_arg(args, int); 
            sum += i; 
        } 
        va_end(args); 
        return sum; 
    }

这一次,第一个值是后面跟随的参数的数量,因此例程将从堆栈中提取这个确切数量的整数并对它们求和。代码的调用方式如下:

    cout << sum(0) << endl;                         // 0 
    cout << sum(6, -6, -5, -4, -3, -2, -1) << endl; // -21 
    cout << sum(3, 10, 20, 30) << endl;             // 60

没有约定来处理确定传递了多少参数的问题。

该例程假设堆栈上的每个项都是int,但在函数的原型中没有关于这一点的信息,因此编译器无法对调用函数时实际使用的参数进行类型检查。如果调用者提供了不同类型的参数,可能会从堆栈中读取错误数量的字节,使得对va_arg的所有其他调用的结果无效。考虑这个:

    cout << sum(3, 10., 20, 30) << endl;

同时按下逗号和句号键很容易,这在输入10参数后发生了。句号意味着10是一个double,因此编译器将一个double值放在堆栈上。当函数使用va_arg宏从堆栈中读取值时,它将把 8 字节的double读取为两个 4 字节的int值,对于 Visual C++生成的代码,这将导致总和为1076101140。这说明了参数列表的类型不安全方面:你无法让编译器对传递给函数的参数进行类型检查。

如果你的函数传递了不同的类型,那么你必须实现一些机制来确定这些参数是什么类型。参数列表的一个很好的例子是 C 的printf函数:

    int printf(const char *format, ...);

这个函数的必需参数是一个格式字符串,而且这个格式字符串包含一个变量参数及其类型的有序列表。格式字符串提供了通过<cstdarg>宏不可用的信息:可变参数的数量和每个参数的类型。printf函数的实现将遍历格式字符串,当它遇到一个参数的格式说明符(以%开头的字符序列)时,它将使用va_arg从堆栈中读取期望的类型。很明显,C 风格的参数列表并不像它们一开始看起来那样灵活;而且,它们可能非常危险。

函数特性

函数是应用程序或库中定义的模块化代码片段。如果一个函数是由另一个供应商编写的,重要的是你的代码以供应商预期的方式调用函数。这意味着理解所使用的调用约定以及它如何影响堆栈。

调用堆栈

当您调用函数时,编译器将为新函数调用创建一个堆栈帧,并将项目推送到堆栈上。放在堆栈上的数据取决于您的编译器以及代码是为调试还是发布构建而编译的;但是,一般来说,将有关于传递给函数的参数、返回地址(函数调用后的地址)以及函数中分配的自动变量的信息。

这意味着,在运行时进行函数调用时,将会有一个内存开销和性能开销,因为在函数运行之前创建堆栈帧,并在函数完成后进行清理,会有性能开销。如果函数是内联的,这种开销就不会发生,因为函数调用将使用当前堆栈帧而不是新的堆栈帧。显然,内联函数应该很小,无论是代码还是堆栈上使用的内存。编译器可以忽略inline说明符,并使用单独的堆栈帧调用函数。

指定调用约定

当您的代码使用自己的函数时,您不需要关注调用约定,因为编译器将确保使用适当的约定。但是,如果您正在编写可以被其他 C++编译器使用,甚至可以被其他语言使用的库代码,那么调用约定就变得重要起来。由于本书不涉及可互操作的代码,我们不会深入讨论,而是将关注两个方面:函数命名和堆栈维护。

使用 C 链接

当您给 C函数命名时,这是您在 C代码中调用函数时将使用的名称。但是,在底层,C编译器将使用额外的符号为返回类型和参数修饰名称,以便重载函数都有不同的名称。对于 C开发人员来说,这也被称为名称修饰

如果您需要通过共享库(在 Windows 中为动态链接库)导出函数,您必须使用其他语言可以使用的类型和名称。为此,您可以使用extern "C"标记函数。这意味着函数具有 C 链接,并且编译器不会使用 C名称修饰。显然,您应该仅在外部代码将使用的函数上使用此功能,并且不应将其用于具有返回值和使用 C自定义类型的参数的函数。但是,如果这样的函数确实返回 C类型,编译器将只发出警告。原因是 C 是一种灵活的语言,C 程序员将能够弄清楚如何将 C类型转换为可用的东西,但滥用它们是不好的做法!

extern "C"链接也可以用于全局变量,并且可以在单个项目上使用它,也可以(使用大括号)在多个项目上使用它。

指定如何维护堆栈

Visual C支持六种调用约定,您可以在函数上使用。__clrcall说明符表示该函数应该作为.NET 函数调用,并允许您编写具有混合本机代码和托管代码的代码。C/CLR(Microsoft 的 C++语言扩展,用于编写.NET 代码)超出了本书的范围。其他五种用于指示参数如何传递给函数(在堆栈上还是使用 CPU 寄存器)以及谁负责维护堆栈。我们只会涵盖三种:__cdecl__stdcall__thiscall

您很少会显式使用__thiscall;它是用于自定义类型成员函数的调用约定,并且指示函数具有一个隐藏参数,该参数是可以通过函数中的this关键字访问的对象的指针。更多细节将在下一章中给出,但重要的是要意识到这样的成员函数具有不同的调用约定,特别是当您需要初始化函数指针时。

默认情况下,C++全局函数将使用__cdecl调用约定。堆栈由调用代码维护,因此在调用代码中,对__cdecl函数的每次调用后都会有清理堆栈的代码。这使得每个函数调用都会变得稍微大一些,但这是为了能够使用可变参数列表。大多数 Windows SDK 函数使用__stdcall调用约定,它表示被调用的函数清理堆栈,因此在调用代码中不需要生成这样的代码。显然,编译器知道函数使用__stdcall是很重要的,否则它将生成代码来清理已经被函数清理的堆栈帧。通常会看到使用WINAPI标记的 Windows 函数,这是__stdcalltypedef

使用递归

在大多数情况下,调用堆栈的内存开销并不重要。然而,当您使用递归时,可能会建立一个很长的堆栈帧链。顾名思义,递归是指一个函数调用自身。一个简单的例子是计算阶乘的函数:

    int factorial(int n) 
    { 
        if (n > 1) return n ∗ factorial(n − 1); 
        return 1; 
    }

如果您为 4 调用此函数,则会进行以下调用:

    factorial(4) returns 4 * factorial(3) 
        factorial(3) returns 3 * factorial(2) 
            factorial(2) returns 2 * factorial(1) 
                factorial(1) returns 1

重要的一点是,在递归函数中必须至少有一种方法可以在没有递归的情况下离开函数。在这种情况下,当使用参数 1 调用factorial时,函数将结束。在实践中,这样的函数应该标记为inline,以避免创建任何堆栈帧。

函数重载

您可以有几个具有相同名称的函数,但参数列表不同(参数的数量和/或参数的类型)。这就是函数重载。当调用这样的函数时,编译器将尝试找到最适合提供的参数的函数。如果没有合适的函数,编译器将尝试转换参数,看看是否存在具有这些类型的函数。编译器将从简单的转换开始(例如,数组名称到指针,类型到const类型),如果失败,编译器将尝试提升类型(例如,boolint)。如果失败,编译器将尝试标准转换(例如,引用到类型)。如果这样的转换导致有多个可能的候选项,那么编译器将发出函数调用模糊的错误。

函数和作用域

编译器在寻找合适的函数时也会考虑函数的作用域。您不能在函数内定义函数,但是您可以在函数的作用域内提供函数原型,并且编译器将尝试(如果需要通过转换)首先调用具有这样原型的函数。考虑以下代码:

    void f(int i)    { /*does something*/ } 
    void f(double d) { /*does something*/ } 

    int main() 
    { 
        void f(double d); 
        f(1); 
        return 0; 
    }

在这段代码中,函数f被重载为一个接受int的版本和一个接受double的版本。通常,如果您调用f(1),那么编译器将调用函数的第一个版本。然而,在main中有一个接受double的版本的原型,并且int可以被转换为double而不会丢失信息。原型在与函数调用相同的作用域内,因此在这段代码中,编译器将调用接受double的版本。这种技术本质上隐藏了带有int参数的版本。

删除函数

有一种比使用作用域更正式的隐藏函数的方法。C++将尝试显式转换内置类型。例如:

    void f(int i);

您可以使用int调用此函数,或者任何可以转换为int的东西:

    f(1); 
    f('c'); 
    f(1.0); // warning of conversion

在第二种情况下,char是一个整数,因此它被提升为int并调用函数。在第三种情况下,编译器将发出警告,表示转换可能会导致数据丢失,但这只是一个警告,所以代码将编译。如果要防止这种隐式转换,可以删除您不希望调用者使用的函数。要做到这一点,提供一个原型并使用语法= delete

    void f(double) = delete; 

    void g() 
    { 
        f(1);   // compiles 
        f(1.0); // C2280: attempting to reference a deleted function 
    }

现在,当代码尝试使用chardouble(或float,它将被隐式转换为double)调用函数时,编译器将发出错误。

按值传递和按引用传递

默认情况下,编译器将按值传递参数,即会创建一个副本。如果传递自定义类型,则会调用其复制构造函数来创建一个新对象。如果传递指向内置类型或自定义类型对象的指针,则指针将按值传递,即在函数堆栈上为参数创建一个新指针,并用传递给函数的内存地址进行初始化。这意味着在函数中,您可以更改指针指向的其他内存(如果您想对该指针进行指针算术运算,则这很有用)。指针指向的数据将通过引用传递,即数据保留在函数外部,但函数可以使用指针来更改数据。同样,如果在参数上使用引用,则表示通过引用传递对象。显然,如果在指针或引用参数上使用const,则这将影响函数是否可以更改指向或引用的数据。

在某些情况下,您可能希望从函数中返回多个值,并且您可能选择使用函数的返回值来指示函数是否正确执行。做到这一点的一种方法是将其中一个参数设置为out参数,即它是一个指针或引用,指向函数将要改变的对象或容器:

    // don't allow any more than 100 items 
    bool get_items(int count, vector<int>& values) 
    { 
        if (count > 100) return false; 
        for (int i = 0; i < count; ++i) 
        { 
            values.push_back(i); 
        } 
        return true; 
    }

要调用此函数,必须创建一个vector对象并将其传递给函数:

    vector<int> items {}; 
    get_items(10, items); 
    for(int i : items) cout << i << ' '; 
    cout << endl

因为values参数是一个引用,这意味着当get_values调用push_back来在values容器中插入一个值时,实际上是将该值插入到items容器中。

如果通过指针传递 out 参数,则重要的是查看指针声明。一个*表示变量是一个指针,两个表示它是一个指向指针的指针。以下函数通过 out 参数返回一个int

    bool get_datum(/*out*/ int *pi);

代码的调用方式如下:

    int value = 0; 
    if (get_datum(&value)) { cout << "value is " << value << endl; } 
    else                   { cout << "cannot get the value" << endl;}

这种返回成功值的模式经常被使用,特别是在访问跨进程或机器边界的数据的代码中。函数返回值可用于提供有关调用失败原因的详细信息(无网络访问?无效的安全凭据?等等),并指示应丢弃 out 参数中的数据。

如果 out 参数有一个双*,那么意味着返回值本身是一个指针,可以是指向单个值或数组的指针:

    bool get_data(/*in/out*/ int *psize, /*out*/ int **pi);

在这种情况下,您使用第一个参数传入您想要的缓冲区的大小,并在返回时通过此参数接收缓冲区的实际大小(它是 in/out),以及第二个参数中的缓冲区的指针:

    int size = 10; 
    int *buffer = nullptr; 
    if (get_data(&size, &buffer)) 
    { 
        for (int i = 0; i < size; ++i) 
        { 
            cout << buffer[i] << endl; 
        } 
        delete [] buffer; 
    }

任何返回内存缓冲区的函数都必须记录谁有责任释放内存。在大多数情况下,通常是调用者,就像这个示例代码中假设的那样。

设计函数

通常函数将对全局数据或由调用者传入的数据进行操作。重要的是,当函数完成时,它将使这些数据处于一致的状态。同样重要的是,函数在访问数据之前可以对数据做出假设。

前置条件和后置条件

函数通常会改变一些数据:传递给函数的值,函数返回的数据,或一些全局数据。在设计函数时,确定将访问和更改的数据,并记录这些规则是很重要的。

函数将具有前置条件,即它将使用的数据的假设。例如,如果一个函数传递了一个文件名,并且意图是从文件中提取一些数据,那么谁负责检查文件是否存在?你可以让函数负责,并且前几行将检查该名称是否是文件的有效路径,并调用操作系统函数来检查文件是否存在。然而,如果你有几个函数将对文件执行操作,你将在每个函数中复制这个检查代码,把这个责任放在调用代码上可能更好。显然,这样的操作可能很昂贵,因此重要的是避免调用代码和函数执行检查。

《第十章》(5a2f36d4-7d0b-4f2d-ae7c-f9e51f5d7dc4.xhtml),诊断和调试,将描述如何添加调试代码,称为断言,你可以将其放在函数中,以检查参数的值,以确保调用代码遵循你设置的前置条件规则。断言是使用条件编译定义的,因此只会出现在调试构建中(即使用调试信息编译的 C++代码)。发布构建(将交付给最终用户的完成代码)将有条件地将断言编译掉;这使得代码更快,如果你的测试足够彻底,你可以确保前置条件得到满足。

你还应该记录函数的后置条件。也就是说,关于函数返回的数据的假设(通过函数返回值、输出参数或引用传递的参数)。后置条件是调用代码将做出的假设。例如,你可能返回一个有符号整数,而函数本来应该返回一个正值,但使用负值来表示错误。通常,返回指针的函数在失败时会返回nullptr。在这两种情况下,调用代码知道需要检查返回值,并且只有在它是正值或不是nullptr时才使用它。

使用不变量

你应该小心记录函数如何使用函数外部的数据。如果函数的意图是改变外部数据,你应该记录函数将做什么。如果你没有明确记录函数对外部数据的影响,那么你必须确保当函数完成时,这些数据保持不变。原因是调用代码只会假设你在文档中所说的,并且改变全局数据的副作用可能会导致问题。有时需要存储全局数据的状态,并在函数返回之前将项目返回到该状态。

我们已经在《第三章》(b1227194-5dda-4c73-a8e8-e8d68382abf0.xhtml)中看到了一个例子,使用了cout对象。cout对象是全局的,它可以通过操纵器进行更改,以使其以某种方式解释数值。如果你在函数中更改它(比如插入hex操纵器),那么当在函数外部使用cout对象时,这个更改将保留下来。

《第三章》(b1227194-5dda-4c73-a8e8-e8d68382abf0.xhtml),探索 C++类型,展示了如何解决这样的问题。在那一章中,你创建了一个名为read16的函数,它从文件中读取 16 个字节,并以十六进制形式和 ASCII 字符形式打印出这些值:

    int read16(ifstream& stm) 
    { 
        if (stm.eof()) return -1;  

        int flags = cout.flags(); 
        cout << hex; 
        string line; 

        // code that changes the line variable 

        cout.setf(flags); 
        return line.length(); 
    }

这段代码将cout对象的状态存储在临时变量flags中。read16函数可以以任何必要的方式更改cout对象,但因为我们有存储的状态,这意味着在返回之前可以将对象恢复到其原始状态。

函数指针

当应用程序运行时,它将调用的函数将存在于内存中的某个位置。这意味着你可以获得函数的地址。C++允许你使用函数调用运算符(一对括号括住参数())通过函数指针调用函数。

记住括号!

首先,一个简单的例子,说明函数指针如何导致代码中难以注意到的错误。一个名为get_status的全局函数执行各种验证操作,以确定系统状态是否有效。该函数返回零表示系统状态有效,大于零的值表示错误代码:

    // values over zero are error codes 
    int get_status() 
    { 
        int status = 0;  
        // code that checks the state of data is valid 
        return status; 
    }

可以像这样调用代码:

    if (get_status > 0) 
    { 
        cout << "system state is invalid" << endl; 
    }

这是一个错误,因为开发人员遗漏了(),所以编译器不会将其视为函数调用。相反,它将其视为对函数的内存地址进行测试,由于函数永远不会位于内存地址为零的位置,比较将始终为true,即使系统状态有效也会打印出消息。

声明函数指针

最后一节强调了获取函数地址有多么容易:你只需使用函数的名称而不带括号:

    void *pv = get_status;

指针pv只是稍微有趣;你现在知道函数存储在内存中的位置,但要打印这个地址,你仍然需要将其转换为整数。为了使指针有用,你需要能够声明一个通过该函数可以被调用的指针。为了看看如何做到这一点,让我们回到函数原型:

    int get_status()

函数指针必须能够调用不带参数并期望返回整数值的函数。函数指针声明如下:

    int (*fn)() = get_status;

*表示变量fn是一个指针;然而,这会绑定到左边,所以如果没有括号包围*fn,编译器会将其解释为int*指针的声明。声明的其余部分表示如何调用这个函数指针:不带参数并返回一个int

通过函数指针调用很简单:在通常给出函数名称的地方给出指针的名称:

    int error_value = fn();

再次注意括号的重要性;它们表示在函数指针fn中保存的地址处调用函数。

函数指针可能会使代码看起来相当凌乱,特别是当你使用它们指向模板函数时,因此通常代码会定义一个别名:

    using pf1 = int(*)();
    typedef int(*pf2)();

这两行为调用get_status函数所需的函数指针类型声明了别名。两者都是有效的,但using版本更易读,因为清楚地表明了pf1是正在定义的别名。为了理解原因,考虑这个别名:

    typedef bool(*MyPtr)(MyType*, MyType*);

类型别名称为MyPtr,它是一个返回bool并接受两个MyType指针的函数。使用using更清晰:

    using MyPtr = bool(*)(MyType*, MyType*);

这里的显著标志是(*),它表示类型是函数指针,因为你使用括号来打破*的关联性。然后你可以向外读取函数的原型:向左看返回类型,向右看参数列表。

一旦你声明了一个别名,你就可以创建一个指向函数的指针并调用它:

    using two_ints = void (*)(int, int); 

    void do_something(int l, int r){/* some code */} 

    void caller() 
    { 
        two_ints fn = do_something; 
        fn(42, 99); 
    }

注意,因为two_ints别名声明为指针,所以在声明此类型的变量时不使用*

使用函数指针

函数指针只是一个指针。这意味着您可以将其用作变量;您可以从函数中返回它,或将其作为参数传递。例如,您可能有一些执行一些冗长例程的代码,并且希望在例程期间提供一些反馈。为了使其灵活,您可以定义函数以接受回调指针,并在例程中定期调用该函数以指示进度:

    using callback = void(*)(const string&); 

    void big_routine(int loop_count, const callback progress) 
    { 
        for (int i = 0; i < loop_count; ++i) 
        { 
            if (i % 100 == 0) 
            { 
                string msg("loop "); 
                 msg += to_string(i); 
                 progress(msg); 
            } 
            // routine 
        } 
    }

在这里,big_routine有一个名为progress的函数指针参数。该函数有一个循环,将被多次调用,每 100 次循环它调用回调函数,传递一个包含有关进度信息的string

请注意,string类定义了+=运算符,可用于将字符串附加到变量中字符串的末尾,而<string>头文件定义了一个名为to_string的函数,该函数对每个内置类型进行了重载,以返回一个使用函数参数值格式化的string

此函数将函数指针声明为const,只是为了让编译器知道该函数指针在此函数中不应更改为指向另一个函数的指针。代码可以这样调用:

    void monitor(const string& msg) 
    { 
        cout << msg << endl; 
    } 

    int main() 
    { 
        big_routine(1000, monitor); 
        return 0; 
    }

monitor函数的原型与callback函数指针描述的原型相同(例如,如果函数参数是string&而不是const string&,那么代码将无法编译)。然后调用big_routine函数,将monitor函数的指针作为第二个参数传递。

如果将回调函数传递给库代码,必须注意函数指针的调用约定。例如,如果将函数指针传递给 Windows 函数(如EnumWindows),它必须指向使用__stdcall调用约定声明的函数。

C++标准使用另一种在运行时调用函数的技术,即函数对象。稍后将对此进行介绍。

模板函数

当编写库代码时,通常必须编写几个函数,这些函数之间的唯一区别在于传递给函数的类型;例程操作相同,只是类型已更改。C++提供了模板,允许您编写更通用的代码;您使用通用类型编写例程,并且在编译时编译器将生成具有适当类型的函数。使用template关键字和尖括号(<>)中的参数列表标记模板函数,这些参数列表为将要使用的类型提供了占位符。重要的是要理解这些模板参数是类型,并且指的是将在调用函数时用实际类型替换的参数(和函数的返回值)。它们不是函数的参数,通常在调用函数时不提供它们。

最好通过示例来解释模板函数。一个简单的maximum函数可以这样写:

    int maximum(int lhs, int rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    }

您可以使用其他整数类型调用此函数,较小的类型(如shortcharbool等)将被提升为int,较大类型(long long)的值将被截断。同样,unsigned类型的变量将被转换为signed int,这可能会导致问题。考虑以下函数调用:

    unsigned int s1 = 0xffffffff, s2 = 0x7fffffff; 
    unsigned int result = maximum(s1, s2);

result变量的值是什么:s1还是s2?是s2。原因是两个值都转换为signed int,当转换为有符号类型时,s1将是值-1,而s2将是值2147483647

要处理无符号类型,需要重载函数,并为有符号和无符号整数编写一个版本:

    int maximum(int lhs, int rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    } 

    unsigned maximum(unsigned lhs, unsigned rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    }

例程相同,但类型已更改。还有另一个问题——如果调用者混合类型会怎样?以下表达式是否有意义:

    int i = maximum(true, 100.99);

这段代码将编译,因为booldouble可以转换为int,并且将调用第一个重载。由于这样的调用是无意义的,如果编译器能捕捉到这个错误将会更好。

定义模板

回到maximum函数的两个版本,它们的例程都是一样的;改变的只是类型。如果你有一个通用类型,让我们称之为T,其中T可以是任何实现operator>的类型,那么这个例程可以用伪代码描述如下:

    T maximum(T lhs, T rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    }

这将不会编译,因为我们没有定义类型T。模板允许你告诉编译器代码使用了一个类型,并且将从传递给函数的参数中确定。以下代码将编译:

    template<typename T> 
    T maximum(T lhs, T rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    }

模板声明使用typename标识符指定将要使用的类型。类型T是一个占位符;你可以使用任何你喜欢的名称,只要它不是在同一作用域中的其他名称,当然,它必须在函数的参数列表中使用。你可以使用class代替typename,但意思是一样的。

你可以调用这个函数,传递任何类型的值,编译器将为该类型创建代码,调用该类型的operator>

重要的是要意识到,当编译器第一次遇到模板函数时,它将为指定的类型创建函数的版本。如果你为几种不同的类型调用模板函数,编译器将为每种类型创建或实例化一个专门的函数。

这个模板的定义表明只会使用一个类型,所以你只能用相同类型的两个参数来调用它:

    int i = maximum(1, 100);
    double d = maximum(1.0, 100.0);
    bool b = maximum(true, false);

所有这些都将编译,前两个将给出预期的结果。最后一行将把b赋值为true,因为bool是一个整数,true的值是1+false的值是0。这可能不是你想要的,所以我们稍后会回到这个问题。请注意,由于模板规定两个参数必须是相同的类型,以下代码将不会编译:

    int i = maximum(true, 100.99);

原因是template参数列表只给出了一个类型。如果你想要定义一个带有不同类型参数的函数,那么你将不得不为模板提供额外的参数:

    template<typename T, typename U> 
    T maximum(T lhs, U rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    }

这样做是为了说明模板是如何工作的;定义一个接受两种不同类型的最大函数实际上是没有意义的。

这个版本是为两种不同的类型编写的,模板声明提到了两种类型,并且这些类型用于两个参数。但请注意,函数返回T,第一个参数的类型。函数可以这样调用:

    cout << maximum(false, 100.99) << endl; // 1 
    cout << maximum(100.99, false) << endl; // 100.99

第一行的输出是1(或者如果你使用bool alpha操作符,是true),第二行的结果是100.99。原因并不是立即显而易见。在两种情况下,比较都将从函数返回100.99,但是因为返回值的类型是T,返回值的类型将是第一个参数的类型。在第一种情况下,100.99首先被转换为bool,由于100.99不是零,返回的值是true(或者1)。在第二种情况下,第一个参数是double,所以函数返回一个double,这意味着返回100.99。如果maximum的模板版本被改为返回U(第二个参数的类型),那么前面代码返回的值将被颠倒:第一行返回100.99,第二行返回1

注意,当你调用模板函数时,你不必给出模板参数的类型,因为编译器会推断它们。重要的是要指出,这仅适用于参数。返回类型不是由调用者分配给函数值的变量的类型决定的,因为函数可以在不使用返回值的情况下被调用。

尽管编译器将根据您调用函数的方式推断模板参数,但您可以在调用的函数中显式提供类型,以调用函数的特定版本,并(如果必要)让编译器执行隐式转换:

    // call template<typename T> maximum(T,T); 
    int i = maximum<int>(false, 100.99);

此代码将调用具有两个int参数并返回intmaximum版本,因此返回值为100,即100.99转换为int

使用模板参数值

到目前为止定义的模板已经将类型作为模板的参数,但您也可以提供整数值。以下是一个相当牵强的例子来说明这一点:

    template<int size, typename T> 
    T* init(T t) 
    { 
        T* arr = new T[size]; 
        for (int i = 0; i < size; ++i) arr[i] = t; 
        return arr; 
    }

有两个模板参数。第二个参数提供了一个类型的名称,其中T是函数参数的类型的占位符。第一个参数看起来像一个函数参数,因为它以类似的方式使用。参数size可以在函数中作为本地(只读)变量使用。函数参数是T,因此编译器可以从函数调用中推断出第二个模板参数,但无法推断出第一个参数,因此您必须在调用中提供一个值。以下是调用此模板函数的示例,Tintsize10的值:

    int *i10 = init<10>(42); 
    for (int i = 0; i < 10; ++i) cout << i10[i] << ' '; 
    cout << endl; 
    delete [] i10;

第一行调用函数,模板参数为10,函数参数为42。由于42是一个intinit函数将创建一个具有十个成员的int数组,每个成员的值都初始化为42。编译器推断int为第二个参数,但此代码也可以调用init<10,int>(42)函数,以明确指示您需要一个int数组。

非类型参数必须在编译时是常量:值可以是整数(包括枚举),但不能是浮点数。您可以使用整数数组,但这些将通过模板参数作为指针可用。

尽管在大多数情况下,编译器无法推断值参数,但如果该值被定义为数组的大小,则可以。这可以用来使函数似乎可以确定内置数组的大小,但当然,它不能,因为编译器将为所需的每个大小创建函数的版本。例如:

    template<typename T, int N> void print_array(T (&arr)[N]) 
    { 
        for (int i = 0; i < N; ++i) 
        { 
            cout << arr[i] << endl; 
        } 
    }

在这里,有两个模板参数:一个是数组的类型,另一个是数组的大小。函数的参数看起来有点奇怪,但它只是通过引用传递的内置数组。如果不使用括号,则参数为T& arr[N],即大小为 N 的引用数组,引用对象的类型为T,这不是我们想要的。我们想要一个大小为 N 的内置数组对象的类型为T。这个函数的调用如下:

    int squares[] = { 1, 4, 9, 16, 25 }; 
    print_array(squares);

前面代码的有趣之处在于编译器看到初始化列表中有五个项目。内置数组有五个项目,因此调用函数如下:

    print_array<int,5>(squares);

如前所述,编译器将为代码调用的每种TN组合实例化此函数。如果模板函数有大量代码,则可能会出现问题。解决此问题的一种方法是使用辅助函数:

    template<typename T> void print_array(T* arr, int size) 
    { 
        for (int i = 0; i < size; ++i) 
        { 
            cout << arr[i] << endl; 
        } 
    } 

    template<typename T, int N> inline void print_array(T (&arr)[N]) 
    { 
        print_array(arr, N); 
    }

这样做两件事。首先,有一个接受指针和指针指向的项目数的print_array版本。这意味着size参数在运行时确定,因此此函数的版本仅在编译时为所使用的数组类型实例化,而不是类型和数组大小。另一个要注意的是,使用数组大小作为模板的函数被声明为inline,并调用函数的第一个版本。尽管对于每种类型和数组大小的组合都会有一个版本,但实例化将是内联的,而不是完整的函数。

专用模板

在某些情况下,您可能有一个适用于大多数类型(并且适合模板函数的候选函数),但您可能会发现某些类型需要不同的例程。为了处理这种情况,您可以编写一个特化的模板函数,也就是说,当调用者使用符合此特化的类型时,编译器将使用此代码。例如,这是一个相当无意义的函数;它返回类型的大小:

    template <typename T> int number_of_bytes(T t) 
    { 
        return sizeof(T); 
    }

这适用于大多数内置类型,但如果使用指针调用它,您将得到指针的大小,而不是指针指向的内容。因此,number_of_bytes("x")将返回 4(在 32 位系统上),而不是char数组的大小为 2。您可能决定为char*指针编写一个使用 C 函数strlen来计算字符串中字符数的特化版本,直到NUL字符。为此,您需要与模板函数类似的原型,将模板参数替换为实际类型,由于不需要模板参数,因此可以省略。由于此函数是为特定类型而设计的,因此需要将特定类型添加到函数名称中。

    template<> int number_of_bytes<const char *>(const char *str) 
    { 
        return strlen(str) + 1; 
    }

现在,当您调用number_of_bytes("x")时,将调用特化版本,并返回值为 2。

之前,我们定义了一个模板函数来返回相同类型的两个参数的最大值:

    template<typename T> 
    T maximum(T lhs, T rhs) 
    { 
        return (lhs > rhs) ? lhs : rhs; 
    }

使用特化,您可以为不使用>运算符进行比较的类型编写版本。由于找到两个布尔值的最大值是没有意义的,您可以删除bool的特化版本。

    template<> bool maximum<bool>(bool lhs, bool rhs) = delete;

这意味着,如果代码使用bool参数调用maximum,编译器将生成错误。

可变模板

可变模板是指模板参数的数量是可变的。语法类似于函数的可变参数;您使用省略号,但是您将它们放在参数列表中参数的左侧,这声明了它为参数包

    template<typename T, typename... Arguments>  
    void func(T t, Arguments... args);

Arguments模板参数是零个或多个类型,它们是函数的相应数量的参数args的类型。在此示例中,函数至少有一个类型为T的参数,但您可以有任意数量的固定参数,包括没有。

在函数内部,您需要解包参数包以访问调用者传递的参数。您可以使用特殊运算符sizeof...(注意省略号是名称的一部分)确定参数包中有多少项;与sizeof运算符不同,这是项数而不是字节大小。要解包参数包,您需要在参数包名称的右侧使用省略号(例如,args...)。编译器将在此时展开参数包,用参数包的内容替换符号。

但是,您在设计时不会知道有多少参数或它们是什么类型,因此有一些策略可以解决这个问题。第一个使用递归:

    template<typename T> void print(T t) 
    { 
        cout << t << endl; 
    } 

    template<typename T, typename... Arguments>  
    void print(T first, Arguments ... next) 
    { 
        print(first); 
        print(next...); 
    }

可变模板print函数可以使用任何可以由ostream类处理的任意类型的一个或多个参数进行调用。

    print(1, 2.0, "hello", bool);

当调用此函数时,参数列表被分成两部分:第一个参数(1)在第一个参数first中,其他三个参数放在参数包next中。然后函数体调用print的第一个版本,将first参数打印到控制台。可变函数的下一行然后展开参数包调用print,也就是递归调用自身。在此调用中,first参数将是2.0,其余参数将放在参数包中。这将继续进行,直到参数包扩展到没有更多参数为止。

解包参数包的另一种方法是使用初始化列表。在这种情况下,编译器将创建一个包含每个参数的数组。

    template<typename... Arguments>  
    void print(Arguments ... args) 
    { 
        int arr [sizeof...(args)] = { args... }; 
        for (auto i : arr) cout << i << endl; 
    }

数组arr的大小与参数包的大小相同,并且使用初始化大括号的解包语法将数组填充为参数。尽管这将适用于任意数量的参数,但所有参数都必须是数组arr的相同类型。

一个技巧是使用逗号运算符:

    template<typename... Arguments>  
    void print(Arguments ... args) 
    { 
        int dummy[sizeof...(args)] = { (print(args), 0)... }; 
    }

这将创建一个名为dummy的虚拟数组。除了在参数包的扩展中使用之外,不使用该数组。该数组的大小与args参数包相同,并且省略号使用括号之间的表达式扩展参数包。表达式使用逗号运算符,它将返回逗号的右侧。由于这是一个整数,这意味着dummy的每个条目的值为零。有趣的部分是逗号运算符的左侧。这里使用具有单个模板化参数的print版本,该版本使用args参数包中的每个项目进行调用。

重载运算符

早些时候我们说过函数名不应包含标点符号。这并不完全正确,因为如果您正在编写运算符,只能在函数名中使用标点符号。运算符用于对一个或多个操作数进行操作的表达式。一元运算符有一个操作数,二元运算符有两个操作数,并且运算符返回操作的结果。显然,这描述了一个函数:返回类型,名称和一个或多个参数。

C++提供了关键字operator,以指示该函数不使用函数调用语法,而是使用与运算符相关的语法调用(通常,一元运算符的第一个参数位于运算符的右侧,而二元运算符的第一个参数位于左侧,第二个参数位于右侧,但也有例外)。

通常,您将提供运算符作为自定义类型的一部分(因此运算符作用于该类型的变量),但在某些情况下,您可以在全局范围内声明运算符。两者都是有效的。如果您正在编写自定义类型(如下一章中所述的类),那么将运算符的代码封装为自定义类型的一部分是有意义的。在本节中,我们将集中讨论定义运算符的另一种方法:作为全局函数。

您可以提供以下一元运算符的自定义版本:

    ! & + - * ++ -- ~

您还可以提供以下二元运算符的自定义版本:

    != == < <= > >= && ||
    % %= + += - -= * *= / /= & &= | |= ^ ^= << <<= = >> =>>
    -> ->* ,

您还可以编写函数调用运算符()、数组下标[]、转换运算符、强制转换运算符()newdelete的版本。您不能重新定义..*::?:###运算符,也不能重新定义“命名”运算符sizeofalignoftypeid

在定义运算符时,您编写一个函数,其中函数名为operator*x*,而*x*是运算符符号(请注意,没有空格)。例如,如果您定义了一个具有两个成员的struct,用于定义笛卡尔点,您可能希望比较两个点是否相等。可以这样定义struct

    struct point 
    { 
        int x; 
        int y; 
    };

比较两个point对象很容易。如果一个对象的xy等于另一个对象中对应的值,则它们相同。如果定义了==运算符,则还应该使用相同的逻辑定义!=运算符,因为!=应该给出==运算符的确切相反结果。这是如何定义这些运算符的方式:

    bool operator==(const point& lhs, const point& rhs) 
    { 
        return (lhs.x == rhs.x) && (lhs.y == rhs.y); 
    } 

    bool operator!=(const point& lhs, const point& rhs) 
    { 
        return !(lhs == rhs); 
    }

这两个参数是运算符的两个操作数。第一个是左侧的操作数,第二个参数是运算符右侧的操作数。它们作为引用传递,以便不进行复制,并且它们标记为const,因为运算符不会改变对象。一旦定义,您可以像这样使用point类型:

    point p1{ 1,1 }; 
    point p2{ 1,1 }; 
    cout << boolalpha; 
    cout << (p1 == p2) << endl; // true 
    cout << (p1 != p2) << endl; // false

你可以定义一对名为equalsnot_equals的函数,并使用这些函数:

    cout << equals(p1,p2) << endl;     // true 
    cout << not_equals(p1,p2) << endl; // false

然而,定义运算符使代码更易读,因为您可以像内置类型一样使用该类型。运算符重载通常被称为语法糖,使代码更易于阅读的语法--但这淡化了一个重要的技术。例如,智能指针是一种涉及类析构函数来管理资源生命周期的技术,仅因为您可以调用此类对象,就好像它们是指针一样。您可以这样做,因为智能指针类实现了->*运算符。另一个例子是函数器,或函数对象,其中类实现了()运算符,以便可以像访问函数一样访问对象。

当您编写自定义类型时,应该问自己是否重载该类型的运算符是有意义的。例如,如果该类型是数值类型,例如复数或矩阵 - 那么实现算术运算符是有意义的,但实现逻辑运算符是否有意义,因为该类型没有逻辑方面?有一种诱惑,即重新定义运算符的含义以涵盖您的特定操作,但这将使您的代码不太可读。

一般来说,一元运算符被实现为接受单个参数的全局函数。后缀递增和递减运算符是一个例外,允许与前缀运算符不同的实现。前缀运算符将具有对对象的引用作为参数(运算符将递增或递减的对象),并返回对此更改后的对象的引用。然而,后缀运算符必须返回递增或递减之前对象的值。因此,运算符函数有两个参数:将被更改的对象的引用和一个整数(始终是 1 的值);它将返回原始对象的副本。

二元运算符将有两个参数并返回一个对象或对象的引用。例如,对于我们之前定义的struct,我们可以为ostream对象定义插入运算符:

    struct point 
    { 
        int x; 
        int y; 
    }; 

    ostream& operator<<(ostream& os, const point& pt) 
    { 
        os << "(" << pt.x << "," << pt.y << ")"; 
        return os; 
    }

这意味着现在可以将point对象插入到cout对象中,以在控制台上打印它:

    point pt{1, 1}; 
    cout << "point object is " << pt << endl;

函数对象

函数对象,或函数器,是实现函数调用运算符(operator())的自定义类型。这意味着可以以类似函数的方式调用函数运算符。由于我们还没有涵盖类,因此在本节中,我们将仅探讨标准库提供的函数对象类型以及如何使用它们。

<functional>头文件包含各种可用作函数对象的类型。以下表列出了这些类型:

目的 类型
算术 dividesminusmodulusmultipliesnegateplus
位运算 bit_andbit_notbit_orbit_xor
比较 equal_togreatergreater_equallessless_equalsnot_equal_to
逻辑 logical_andlogical_notlogical_or

这些都是二元函数类,除了bit_notlogical_notnegate是一元的。二元函数对象作用于两个值并返回一个结果,一元函数对象作用于单个值并返回一个结果。例如,您可以使用以下代码计算两个数字的模:

    modulus<int> fn; 
    cout << fn(10, 2) << endl;

这声明了一个名为fn的函数对象,将执行模运算。该对象在第二行中使用,该行调用对象上的operator()函数,带有两个参数,因此以下行等同于前一行:

    cout << fn.operator()(10, 2) << endl;

结果是在控制台上打印出0的值。operator()函数仅对两个参数执行模运算,在这种情况下是10 % 2。这看起来并不太令人兴奋。<algorithm>头文件包含可以用于函数对象的函数。大多数采用谓词,即逻辑函数对象,但transform采用执行操作的函数对象:

    // #include <algorithm> 
    // #include <functional> 

    vector<int> v1 { 1, 2, 3, 4, 5 }; 
    vector<int> v2(v1.size()); 
    fill(v2.begin(), v2.end(), 2); 
    vector<int> result(v1.size()); 

    transform(v1.begin(), v1.end(), v2.begin(), 
        result.begin(), modulus<int>()); 

    for (int i : result) 
    { 
        cout << i << ' '; 
    } 
    cout << endl;

这段代码将对两个向量中的值执行五次模运算。在概念上,它是这样做的:

    result = v1 % v2;

也就是说,result中的每个项目都是v1v2中对应项目的模。在代码中,第一行创建了一个具有五个值的vector。我们将用2计算这些值的模,因此第二行声明了一个空的vector,但容量与第一个vector相同。通过调用fill函数来填充这个第二个vector。第一个参数是vector中第一个项目的地址,end函数返回vector最后一个项目之后的地址。函数调用的最后一个项目是将放置在从第一个参数指向的项目开始到第二个参数指向的项目之前(不包括)的vector中的值。

此时,第二个vector将包含五个项目,每个项目都是2。接下来,创建了一个用于结果的vector;同样,它的大小与第一个数组相同。最后,通过transform函数执行计算,再次显示如下:

    transform(v1.begin(), v1.end(),  
       v2.begin(), result.begin(), modulus<int>());

前两个参数给出了第一个vector的迭代器,从中可以计算出项目的数量。由于所有三个vector的大小相同,因此只需要v2resultbegin迭代器。

最后一个参数是函数对象。这是一个临时对象,仅在此语句期间存在;它没有名称。这里使用的语法是对类的构造函数的显式调用;它是模板化的,因此需要给出模板参数。transform函数将对v1中的每个项目调用此函数对象的operator(int,int)函数作为第一个参数,并将v2中的相应项目作为第二参数,并将结果存储在result中的相应位置。

由于transform将任何二进制函数对象作为第二个参数,您可以传递plus<int>的实例来将v1中的每个项目加 2,或者传递multiplies<int>的实例来将v1中的每个项目乘以 2。

函数对象有用的一种情况是使用谓词进行多个比较。谓词是一个比较值并返回布尔值的函数对象。<functional>头文件包含几个类,允许您比较项目。让我们看看result容器中有多少个项目是零。为此,我们使用count_if函数。这将遍历容器,对每个项目应用谓词,并计算谓词返回true值的次数。有几种方法可以做到这一点。第一种定义了一个谓词函数:

    bool equals_zero(int a) 
    { 
        return (a == 0); 
    }

然后可以将指向此的指针传递给count_if函数:

    int zeros = count_if( 
       result.begin(), result.end(), equals_zero);

前两个参数指示要检查的值的范围。最后一个参数是用作谓词的函数的指针。当然,如果要检查不同的值,可以使其更通用:

    template<typename T, T value> 
    inline bool equals(T a) 
    { 
        return a == value; 
    }

像这样调用它:

    int zeros = count_if( 
       result.begin(), result.end(), equals<int, 0>);

这段代码的问题在于我们在使用操作的地方之外定义了它。equals函数可以在另一个文件中定义;然而,使用谓词时,将检查代码定义在需要谓词的代码附近更易读。

<functional>头文件还定义了可以用作函数对象的类。例如,equal_to<int>,用于比较两个值。但是,count_if函数期望一个一元函数对象,它将传递一个单个值(参见前面描述的equals_zero函数)。equal_to<int>是一个二元函数对象,用于比较两个值。我们需要提供第二个操作数,为此我们使用名为bind2nd的辅助函数:

    int zeros = count_if( 
       result.begin(), result.end(), bind2nd(equal_to<int>(), 0));

bind2nd将参数0绑定到从equal_to<int>创建的函数对象。像这样使用函数对象将谓词的定义与将使用它的函数调用更加接近,但语法看起来相当混乱。C++11 提供了一种机制,可以让编译器确定所需的函数对象并将参数绑定到它们。这些被称为 lambda 表达式。

引入 lambda 表达式

Lambda 表达式用于在将使用函数对象的位置创建匿名函数对象。这使得您的代码更易读,因为您可以看到将执行什么。乍一看,lambda 表达式看起来像是在函数参数的地方定义的函数:

    auto less_than_10 = [](int a) {return a < 10; }; 
    bool b = less_than_10(4);

为了避免使用谓词的函数的复杂性,在此代码中,我们将一个变量分配给 lambda 表达式。这通常不是您使用它的方式,但这样可以使描述更清晰。lambda 表达式开头的方括号称为捕获列表。此表达式不捕获变量,因此方括号为空。您可以使用在 lambda 表达式外声明的变量,并且这些变量必须被捕获。捕获列表指示所有这些变量是否将被引用捕获(使用[&])还是值捕获(使用[=])。您还可以命名将被捕获的变量(如果有多个,则使用逗号分隔的列表),如果它们被值捕获,只需使用它们的名称。如果它们被引用捕获,使用它们的名称上加&

您可以通过引入在表达式外声明的名为limit的变量,使前面的 lambda 表达式更通用:

    int limit = 99; 
    auto less_than = limit {return a < limit; };

如果将 lambda 表达式与全局函数进行比较,捕获列表有点像标识全局函数可以访问的全局变量。

在捕获列表之后,您在括号中给出参数列表。同样,如果将 lambda 与函数进行比较,lambda 参数列表等同于函数参数列表。如果 lambda 表达式没有任何参数,则可以完全省略括号。

lambda 的主体在一对大括号中给出。这可以包含任何可以在函数中找到的内容。lambda 主体可以声明局部变量,甚至可以声明static变量,这看起来很奇怪,但是合法的:

    auto incr = [] { static int i; return ++i; }; 
    incr(); 
    incr(); 
    cout << incr() << endl; // 3

Lambda 的返回值是从返回的项目中推断出来的。Lambda 表达式不一定要返回一个值,如果不返回值,表达式将返回void

    auto swap = [](int& a, int& b) { int x = a; a = b; b = x; }; 
    int i = 10, j = 20; 
    cout << i << " " << j << endl; 
    swap(i, j); 
    cout << i << " " << j << endl;

Lambda 表达式的强大之处在于您可以在需要函数对象或谓词的情况下使用它们:

    vector<int> v { 1, 2, 3, 4, 5 }; 
    int less_than_3 = count_if( 
       v.begin(), v.end(),  
       [](int a) { return a < 3; }); 
    cout << "There are " << less_than_3 << " items less than 3" << endl;

在这里,我们声明一个vector并用一些值初始化它。count_if函数用于计算容器中小于 3 的项目数。因此,前两个参数用于指定要检查的项目范围,第三个参数是执行比较的 lambda 表达式。count_if函数将为传递给 lambda 的a参数的范围中的每个项目调用此表达式。count_if函数将持续计算 lambda 返回true的次数。

在 C++中使用函数

本章的示例使用了本章学到的技术,列出了文件夹和子文件夹中所有文件的文件大小,并按文件大小顺序给出文件名和它们的大小。该示例相当于在命令行中输入以下内容:

dir /b /s /os /a-d folder

这里,folder是您要列出的文件夹。/s选项递归,/a-d从列表中删除文件夹,/os按大小排序。问题在于,如果没有/b选项,我们会得到有关每个文件夹的信息,但使用它会删除列表中的文件大小。我们希望得到一个文件名(及其路径)和大小的列表,按最小的顺序排列。

首先,在Beginning_C++文件夹下创建一个新的文件夹(Chapter_05)用于本章。在 Visual C中创建一个新的 C源文件,并将其保存为files.cpp,保存在这个新文件夹下。该示例将使用基本的输出和字符串。它将接受一个命令行参数;如果传递了更多的命令行参数,我们将只使用第一个。将以下内容添加到files.cpp中:

    #include <iostream> 
    #include <string> 
    using namespace std; 

    int main(int argc, char* argv[]) 
    { 
        if (argc < 2) return 1; 
        return 0; 
    }

该示例将使用 Windows 函数FindFirstFileFindNextFile来获取符合文件规范的文件的信息。这些函数返回WIN32_FIND_DATAA结构中的数据,该结构包含有关文件名、文件大小和文件属性的信息。这些函数还返回有关文件夹的信息,因此我们可以测试子文件夹并进行递归。WIN32_FIND_DATAA结构以两部分的 64 位数字给出文件大小:高 32 位和低 32 位。我们将创建自己的结构来保存这些信息。在文件顶部,在 C++包含文件之后,添加以下内容:

    using namespace std; 

    #include <windows.h> struct file_size { unsigned int high; unsigned int low; };

第一行是 Windows SDK 头文件,以便您可以访问 Windows 函数,该结构用于保存有关文件大小的信息。我们想要通过它们的大小来比较文件。WIN32_FIND_DATAA结构提供了两个unsigned long成员的大小(一个带有高 4 字节,另一个带有低 4 字节)。我们可以将其存储为 64 位数字,但是为了有借口编写一些操作符,我们将大小存储在我们的file_size结构中。该示例将打印文件大小并比较文件大小,因此我们将编写一个操作符将file_size对象插入输出流;由于我们想按大小对文件进行排序,我们需要一个操作符来确定一个file_size对象是否大于另一个。

该代码将使用 Windows 函数获取有关文件的信息,特别是它们的名称和大小。这些信息将存储在一个vector中,因此在文件顶部添加这两行:

    #include <string> 
    #include <vector>
 #include <tuple>

tuple类是必需的,以便我们可以将string(文件名)和file_size对象作为vector中的每个项目进行存储。为了使代码更易读,在结构定义之后添加以下别名:

    using file_info = tuple<string, file_size>;

然后在main函数的上面添加获取文件夹中文件的函数的框架代码:

    void files_in_folder( 
       const char *folderPath, vector<file_info>& files) 
    { 
    }

该函数接受一个vector的引用和一个文件夹路径。代码将遍历指定文件夹中的每个项目。如果它是一个文件,它将在vector中存储详细信息;否则,如果该项目是一个文件夹,它将调用自身以获取该子文件夹中的文件。在main函数的底部添加对该函数的调用:

    vector<file_info> files; 
    files_in_folder(argv[1], files);

代码已经检查了至少有一个命令行参数,并将其用作要检查的文件夹。main函数应该打印文件信息,因此我们在堆栈上声明一个vector并将其通过引用传递给files_in_folder函数。到目前为止,这段代码什么也没做,但您可以编译代码以确保没有拼写错误(记得使用/EHsc参数)。

大部分工作是在files_in_folder函数中完成的。首先,在该函数中添加以下代码:

    string folder(folderPath); 
    folder += "*"; 
    WIN32_FIND_DATAA findfiledata {}; 
    void* hFind = FindFirstFileA(folder.c_str(), &findfiledata); 

    if (hFind != INVALID_HANDLE_VALUE) 
    { 
       do 
       { 
       } while (FindNextFileA(hFind, &findfiledata)); 
       FindClose(hFind); 
    }

我们将使用函数的 ASCII 版本(因此在结构和函数名称后面加上A)。FindFirstFileA函数接受搜索路径,在这种情况下,我们使用文件夹的名称后缀为*,表示此文件夹中的所有内容。请注意,Windows 函数需要const char*参数,因此我们在string对象上使用c_str函数。如果函数调用成功并找到符合此条件的项目,那么函数会填充传递的WIN32_FIND_DATAA结构的引用,并且还会返回一个不透明指针,该指针将用于对此搜索进行后续调用(您不需要知道它指向什么)。代码检查调用是否成功,如果成功,它将重复调用FindNextFileA以获取下一个项目,直到此函数返回 0,表示没有更多项目。将不透明指针传递给FindNextFileA,以便它知道正在检查哪个搜索。搜索完成后,代码调用FindClose以释放 Windows 为搜索分配的任何资源。

搜索将返回文件和文件夹项目;要分别处理每个项目,我们可以测试WIN32_FIND_DATAA结构的dwFileAttributes成员。在do循环中添加以下代码:

    string findItem(folderPath); 
    findItem += ""; 
    findItem += findfiledata.cFileName; 
    if ((findfiledata.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) 
    { 
        // this is a folder so recurse 
    } 
    else 
    { 
        // this is a file so store information 
    }

WIN32_FIND_DATAA 结构仅包含文件夹中项目的相对名称,因此前几行创建了绝对路径。接下来的几行测试项目是文件夹(目录)还是文件。如果项目是文件,那么我们只需将其添加到传递给函数的向量中。将以下内容添加到else子句中:

    file_size fs{}; 
    fs.high = findfiledata.nFileSizeHigh; 
    fs.low = findfiledata.nFileSizeLow; 
    files.push_back(make_tuple(findItem, fs));

前三行初始化了一个file_size结构,并且最后一行将带有文件名和大小的tuple添加到vector中。因此,为了看到对此函数的简单调用的结果,请将以下内容添加到main函数的底部:

    for (auto file : files) 
    { 
        cout << setw(16) << get<1>(file) << " "  
            << get<0>(file) << endl; 
    }

这通过files向量中的项目进行迭代。每个项目都是一个tuple<string, file_size>对象,要获取string项目,可以使用标准库函数get,使用 0 作为函数模板参数,要获取file_size对象,可以使用 1 作为函数模板参数调用get。代码调用setw操纵符,以确保文件大小始终以 16 个字符宽的列打印。要使用此功能,需要在文件顶部添加<iomanip>的包含。请注意,get<1>将返回一个file_size对象,并将其插入cout。就目前而言,此代码将无法编译,因为没有运算符可以执行此操作。我们需要编写一个。

在结构的定义之后,添加以下代码:

    ostream& operator<<(ostream& os, const file_size fs) 
    { 
        int flags = os.flags(); 
        unsigned long long ll = fs.low + 
            ((unsigned long long)fs.high << 32); 
        os << hex << ll; 
        os.setf(flags); 
        return os; 
    }

此运算符将更改ostream对象,因此我们在函数开始时存储初始状态,并在函数结束时将对象恢复到此状态。由于文件大小是 64 位数字,我们将file_size对象的组成部分转换为十六进制数,然后将其打印出来。

现在您可以编译并运行此应用程序。例如:

files C:windows

这将列出windows文件夹中文件的名称和大小。

还有两件事需要做-递归子文件夹和对数据进行排序。这两者都很容易实现。在files_in_folder函数中,将以下代码添加到if语句的代码块中:

    // this is a folder so recurse 
    string folder(findfiledata.cFileName); 
    // ignore . and .. directories 
    if (folder != "." && folder != "..") 
    { 
        files_in_folder(findItem.c_str(), files); 
    }

搜索将返回.(当前)文件夹和..(父级)文件夹,因此我们需要检查并忽略它们。下一步是递归调用files_in_folder函数,以获取子文件夹中的文件。如果愿意,可以编译和测试应用程序,但这次最好使用Beginning_C++文件夹来测试代码,因为递归列出 Windows 文件夹将产生大量文件。

代码返回了获取的文件列表,但我们希望按文件大小顺序查看它们。为此,我们可以使用<algorithm>头文件中的sort函数,因此在<tuple>的包含之后添加一个包含。在main函数中,在调用files_in_folder之后,添加以下代码:

    files_in_folder(argv[1], files); 

    sort(files.begin(), files.end(), 
        [](const file_info& lhs, const file_info& rhs) { 
            return get<1>(rhs) > get<1>(lhs);    
    } );

sort函数的前两个参数表示要检查的项目范围。第三个项目是一个谓词,函数将把vector中的两个项目传递给谓词。如果两个参数顺序正确(第一个小于第二个),则必须返回true值。

谓词由 lambda 表达式提供。没有捕获的变量,所以表达式以[]开头,然后是被sort算法比较的项目的参数列表(通过const引用传递,因为它们不会被改变)。实际的比较是在大括号之间进行的。由于我们想要按升序列出文件,我们必须确保两者中的第二个比第一个大。在这段代码中,我们使用>运算符对两个file_size对象进行比较。为了使这段代码编译通过,我们需要定义这个运算符。在插入运算符之后添加以下内容:

    bool operator>(const file_size& lhs, const file_size& rhs) 
    { 
        if (lhs.high > rhs.high) return true; 
        if (lhs.high == rhs.high) { 
            if (lhs.low > rhs.low) return true; 
        } 
        return false; 
    }

现在您可以编译示例并运行它。您应该发现指定文件夹和子文件夹中的文件按文件大小顺序列出。

总结

函数允许您将代码分割成逻辑程序,这样可以使您的代码更易读,并且具有能够重用代码的灵活性。C++提供了丰富的选项来定义函数,包括可变参数列表、模板、函数指针和 lambda 表达式。然而,全局函数存在一个主要问题:数据与函数是分离的。这意味着函数必须通过全局数据项访问数据,或者数据必须在每次调用函数时通过参数传递给函数。在这两种情况下,数据存在于函数之外,可能被与数据无关的其他函数使用。下一章将提供解决方案:类。class允许您将数据封装在自定义类型中,并且可以在该类型上定义函数,以便只有这些函数才能访问数据。

第六章:类

C++允许您创建自己的类型。这些自定义类型可以有运算符,并且可以转换为其他类型;实际上,它们可以像内置类型一样使用,具有您定义的行为。这种功能使用一种称为类的语言特性。能够定义自己的类型的优势在于,您可以将数据封装在您选择的类型的对象中,并使用该类型来管理该数据的生命周期。您还可以定义可以对该数据执行的操作。换句话说,您可以定义具有状态和行为的自定义类型,这是面向对象编程的基础。

编写类

当您使用内置类型时,数据直接可供访问该数据的任何代码。C++提供了一种机制(const)来防止写访问,但任何代码都可以使用const_cast来取消const。您的数据可能很复杂,例如指向映射到内存中的文件的指针,意图是您的代码将更改一些字节,然后将文件写回磁盘。这样的原始指针是危险的,因为具有访问指针的其他代码可能更改不应更改的缓冲区的一部分。所需的是一种将数据封装到了解要更改哪些字节的类型中,并且只允许该类型访问数据的机制。这是类的基本思想。

审查结构

我们已经在 C++中看到了一种封装数据的机制:struct。结构允许您声明内置类型、指针或引用的数据成员。当您从该struct创建变量时,您正在创建该结构的实例,也称为对象。您可以创建引用此对象的变量或指向该对象的指针。甚至可以将对象按值传递给函数,编译器将对对象进行复制(它将调用struct复制构造函数)。我们已经看到,对于struct,任何可以访问实例的代码(甚至通过指针或引用)都可以访问对象的成员(尽管这可能会更改)。在这种情况下,struct可以被视为包含状态的聚合类型。

struct的实例的成员可以通过直接使用点运算符或通过指向对象的指针使用->运算符进行初始化。我们还看到您可以使用初始化器列表(用大括号括起来)初始化struct的实例。这是相当受限制的,因为初始化器列表必须与struct中的数据成员匹配。在第四章中,使用内存、数组和指针,您看到可以将指针作为struct的成员,但必须明确采取适当的操作来释放指针指向的内存;如果不这样做,可能会导致内存泄漏。

struct是您可以在 C中使用的类类型之一;另外两个是unionclass。定义为structclass的自定义类型可以具有行为和状态,C允许您定义一些特殊函数来控制如何创建和销毁实例,复制和转换。此外,您可以在structclass类型上定义运算符,以便您可以类似于在内置类型上使用运算符一样在实例上使用运算符。structclass之间存在差异,我们将在后面讨论,但一般来说,本章的其余部分将涉及类,当提到class时,通常可以假定相同的内容也适用于struct

定义类

类在一个语句中定义,并且它将在由大括号{}括起来的多个语句的块中定义其成员。因为它是一个语句,所以你必须在最后的大括号后面放一个分号。一个类可以在头文件中定义(就像许多C++标准库类一样),但你必须采取措施确保这样的文件只在源文件中包含一次。第一章,从 C++开始,描述了如何使用#pragma once、条件编译和预编译头文件来实现这一点。然而,关于类中特定项目的一些规则必须在源文件中定义,这将在后面介绍。

如果你浏览 C标准库,你会发现类包含成员函数,并且为了将类的所有代码放入单个头文件中,这使得代码难以阅读和理解。这可能是有道理的,对于一个由专家 C程序员组成的军团维护的库文件,但对于你自己的项目来说,可读性应该是一个关键的设计目标。因此,C类可以在 C头文件中声明,包括其成员函数,并且函数的实际实现可以放在源文件中。这使得头文件更容易维护和更可重用。

定义类行为

一个类可以定义只能通过类的实例调用的函数;这样的函数通常被称为方法。对象将具有状态;这是由类定义的数据成员提供的,并在创建对象时初始化。对象上的方法定义了对象的行为,通常作用于对象的状态。当你设计一个类时,你应该这样考虑方法:它们描述了对象正在做某事。

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double get_magnitude() { return std::sqrt((x * x) + (y * y)); } 
    };

这个类有两个数据成员,xy,它们表示笛卡尔 x 和 y 方向上的二维向量的方向。public关键字意味着在此指定符号之后定义的任何成员都可以被类外定义的代码访问。默认情况下,类的所有成员都是private,除非你另有说明。这样的访问说明符将在下一章中更深入地介绍,但private意味着该成员只能被类的其他成员访问。

这是structclass之间的区别:默认情况下,struct的成员是public,默认情况下,class的成员是private

这个类有一个名为get_magnituide的方法,它将返回笛卡尔向量的长度。这个函数作用于类的两个数据成员并返回一个值。这是一种访问器方法;它允许访问对象的状态。这样的方法在class上很典型,但并不要求方法返回值。像函数一样,方法也可以接受参数。get_magnituide方法可以这样调用:

    cartesian_vector vec { 3.0, 4.0 }; 
    double len = vec.get_magnitude(); // returns 5.0

这里创建了一个cartesian_vector对象,并使用列表初始化语法将其初始化为表示向量(3,4)的值。这个向量的长度是 5,这是通过在对象上调用get_magnitude返回的值。

使用 this 指针

类中的方法有一个特殊的调用约定,在 Visual C++中称为__thiscall。原因是类中的每个方法都有一个名为this的隐藏参数,它是指向当前实例的类类型的指针:

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double get_magnitude() 
        { 
             return std::sqrt((this->x * this->x) + (this->y * this->y)); 
        } 
    };

在这里,get_magnitude方法返回cartesian_vector对象的长度。对象的成员通过->运算符访问。如前所示,可以在没有this指针的情况下访问类的成员,但这确实明确了这些项目是class的成员。

你可以在cartesian_vector类型上定义一个方法,允许你改变它的状态:

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        reset(double x, double y) { this->x = x; this->y = y; } 
        // other methods 
    };

reset方法的参数与类的数据成员具有相同的名称;然而,由于我们使用了this指针,编译器知道这不会产生歧义。

你可以使用*运算符对this指针进行解引用,以访问对象。当一个成员函数必须返回对当前对象的引用时(正如我们将在后面看到的一些操作符),你可以通过返回*this来实现。类中的一个方法也可以将this指针传递给外部函数,这意味着它通过一个类型化的指针以引用的方式传递当前对象。

使用作用域解析运算符

你可以在class语句中内联定义一个方法,但你也可以分开声明和实现,所以方法在class语句中声明,但在其他地方定义。当在class语句之外定义一个方法时,你需要使用作用域解析运算符为方法提供类型的名称。例如,使用之前的cartesian_vector示例:

    class cartesian_vector 
    { 
    public: 
        double x; 
        double y; 
        // other methods 
        double magnitude(); 
    }; 

    double cartesian_vector::magnitude() 
    { 
        return sqrt((this->x * this->x) + (this->y * this->y)); 
    }

该方法在类定义之外定义;然而,它仍然是类方法,因此它有一个this指针,可以用来访问对象的成员。通常,类将在头文件中声明方法的原型,并且实际方法将在单独的源文件中实现。在这种情况下,使用this指针来访问类成员(方法和数据成员)在浏览源文件时是显而易见的,这些函数是类的方法。

定义类的状态

你的类可以有内置类型作为数据成员,也可以有自定义类型。这些数据成员可以在类中声明(并在类的实例构造时创建),也可以是指向在自由存储中创建的对象的指针,或者是引用在其他地方创建的对象。请记住,如果你有一个指向在自由存储中创建的项目的指针,你需要知道谁负责释放指针指向的内存。如果你有一个引用(或指针)指向在某个栈帧上创建的对象,你需要确保你的类的对象不会比该栈帧存在的时间更长。

当你将数据成员声明为public时,这意味着外部代码可以读取和写入数据成员。你可以决定只提供只读访问,这种情况下你可以将成员设为private,并通过访问器提供读取访问:

    class cartesian_vector 
    { 
        double x; 
        double y; 
    public: 
        double get_x() { return this->x; } 
        double get_y() { return this->y; } 
        // other methods 
    };

当你将数据成员设为private时,意味着你不能使用初始化列表语法来初始化对象,但我们稍后会解决这个问题。你可以决定使用访问器来给数据成员提供写入访问,并使用这个来检查值。

    void cartesian_vector::set_x(double d) 
    { 
        if (d > -100 && d < 100) this->x = d; 
    }

这是一个范围值必须在(但不包括)-100 和 100 之间的类型。

创建对象

你可以在堆栈上或自由存储中创建对象。使用前面的示例,这是如下所示的:

    cartesian_vector vec { 10, 10 }; 
    cartesian_vector *pvec = new cartesian_vector { 5, 5 }; 
    // use pvec 
    delete pvec

这是对象的直接初始化,假设cartesian_vector的数据成员是publicvec对象在堆栈上创建,并用初始化列表初始化。在第二行,一个对象在自由存储中创建,并用初始化列表初始化。自由存储中的对象必须在某个时刻被释放,这是通过删除指针来实现的。new运算符将在自由存储中为类的数据成员和类需要的任何基础设施分配足够的内存(如下一章所述)。

C++11 的一个新特性是允许直接初始化以在类中提供默认值:

    class point 
    { 
    public: 
        int x = 0; 
        int y = 0; 
    };

这意味着如果你创建一个point的实例而没有任何其他初始化值,它将被初始化,使得xy都为零。如果数据成员是内置数组,那么你可以在类中使用初始化列表进行直接初始化:

    class car 
    { 
    public: 
        double tire_pressures[4] { 25.0, 25.0, 25.0, 25.0 }; 
    };

C++标准库容器可以使用初始化列表进行初始化,因此,在这个tire_pressures类中,我们可以使用vector<double>array<double,4>来代替声明类型为double[4],并以相同的方式进行初始化。

对象的构造

C允许您定义特殊的方法来执行对象的初始化。这些被称为构造函数。在 C11 中,默认情况下会为您生成三个这样的函数,但如果您愿意,也可以提供自己的版本。这三个构造函数以及另外三个相关函数如下:

  • **默认构造函数:**用于创建具有默认值的对象。

  • **拷贝构造函数:**用于基于现有对象的值创建新对象。

  • **移动构造函数:**用于使用从现有对象移动的数据创建新对象。

  • **析构函数:**用于清理对象使用的资源。

  • **拷贝赋值:**将数据从一个现有对象复制到另一个现有对象。

  • **移动赋值:**将数据从一个现有对象移动到另一个现有对象。

这些函数的编译器创建的版本将被隐式设为public;但是,您可以决定通过定义自己的版本并将它们设为private,或者使用=delete语法删除它们来阻止复制或赋值。您还可以提供自己的构造函数,以便使用您决定需要初始化新对象的任何参数。

构造函数是一个与类型相同的成员函数,但不返回值,因此如果构造失败,您无法返回值,这可能意味着调用者将收到一个部分构造的对象。处理这种情况的唯一方法是抛出异常(在第十章中有解释,诊断和调试)。

定义构造函数

当创建一个没有值的对象时,将使用默认构造函数,因此对象将必须用默认值进行初始化。之前声明的point可以这样实现:

    class point 
    { 
        double x; double y; 
    public: 
        point() { x = 0; y = 0; } 
    };

这将明确将项目初始化为零。如果要使用默认值创建实例,则不包括括号。

    point p;   // default constructor called

重要的是要注意这种语法,因为很容易出错写成以下形式:

    point p();  // compiles, but is a function prototype!

这将编译通过,因为编译器会认为您正在提供一个函数原型作为前向声明。但是,当您尝试将符号p用作变量时,将会出现错误。您还可以使用空括号的初始化列表语法调用默认构造函数:

    point p {};  // calls default constructor

虽然在这种情况下并不重要,因为数据成员是内置类型,但是在构造函数的主体中像这样初始化数据成员涉及对成员类型的赋值运算符的调用。更有效的方法是使用成员列表进行直接初始化。

以下是一个构造函数,它接受两个参数,用于说明成员列表:

    point(double x, double y) : x(x), y(y) {}

括号外的标识符是类成员的名称,括号内的项目是用于初始化该成员的表达式(在本例中是构造函数参数)。此示例使用xy作为参数名称。您不必这样做;这里只是作为一个例子,说明编译器将区分参数和数据成员。您还可以在构造函数的成员列表中使用大括号初始化语法:

    point(double x, double y) : x{x}, y{y} {}

创建对象时调用此构造函数:

    point p(10.0, 10.0);

您还可以创建对象数组:

    point arr[4];

这将创建四个point对象,可以通过对arr数组进行索引来访问。请注意,当创建对象数组时,将调用默认构造函数;没有办法调用任何其他构造函数,因此必须分别初始化每个对象。

您还可以为构造函数参数提供默认值。在下面的代码中,car类为四个轮胎(前两个是前轮)和备用轮胎提供了值。有一个构造函数具有用于前后轮的强制值,并为备用轮胎提供了一个可选值。如果未提供备用轮胎压力的值,则将使用默认值:

    class car 
    { 
        array<double, 4> tire_pressures;; 
        double spare; 
    public: 
        car(double front, double back, double s = 25.0)  
          : tire_pressures{front, front, back, back}, spare{s} {} 
    };

此构造函数可以使用两个值或三个值调用:

    car commuter_car(25, 27); 
    car sports_car(26, 28, 28);

委托构造函数

构造函数可以使用相同的成员列表语法调用另一个构造函数:

    class car 
    { 
        // data members 
    public: 
        car(double front, double back, double s = 25.0)  
           : tire_pressures{front, front, back, back}, spare{s} {} 
        car(double all) : car(all, all) {} 
    };

在这里,接受一个值的构造函数委托给接受三个参数的构造函数(在这种情况下使用备用值)。

复制构造函数

当您按值传递对象(或按值返回)或者明确基于另一个对象构造对象时,将使用复制构造函数。以下代码的最后两行都从另一个point对象创建一个point对象,并且在两种情况下都调用了复制构造函数:

    point p1(10, 10); 
    point p2(p1); 
    point p3 = p1;

最后一行看起来涉及赋值运算符,但实际上调用了复制构造函数。复制构造函数可以这样实现:

    class point 
    { 
        int x = 0;int y = 0; 
    public: 
        point(const point& rhs) : x(rhs.x), y(rhs.y) {} 
    };

初始化访问另一个对象(rhs)的private数据成员。这是可以接受的,因为构造函数参数与正在创建的对象的类型相同。复制操作可能不像这样简单。例如,如果类包含一个指针数据成员,您很可能希望复制指针指向的数据,并且这将涉及在新对象中创建一个新的内存缓冲区。

类型转换

您还可以执行转换。在数学中,您可以定义表示方向的向量,以便在两点之间绘制的线是一个向量。在我们的代码中,我们已经定义了point类和cartesian_vector类。您可以决定创建一个构造函数,该构造函数创建原点和一个点之间的向量,在这种情况下,您正在将point对象转换为cartesian_vector对象:

    class cartesian_vector 
    { 
        double x; double y;  
    public: 
        cartesian_vector(const point& p) : x(p.x), y(p.y) {} 
    };

这里有一个问题,我们稍后会解决。转换可以这样调用:

    point p(10, 10); 
    cartesian_vector v1(p); 
    cartesian_vector v2 { p }; 
    cartesian_vector v3 = p;

建立友谊

上面的代码问题在于cartesian_vector类访问point类的private成员。由于我们编写了两个类,我们很乐意打破规则,因此我们将cartesian_vector类设置为point类的friend

    class cartesian_vector; // forward decalartion 

    class point 
    { 
        double x; double y; 
    public: 
        point(double x, double y) : x(x), y(y){} 
        friend class cartesian_point; 
    };

由于cartesian_vector类是在point类之后声明的,我们必须提供一个前向声明,告诉编译器名称cartesian_vector即将被使用,并且将在其他地方声明。重要的一行以friend开头。这表明整个cartesian_vector类的代码可以访问point类的私有成员(数据和方法)。

您还可以声明friend函数。例如,您可以声明一个运算符,以便point对象可以插入cout对象,因此可以将其打印到控制台。您不能更改ostream类,但可以定义全局方法:

    ostream& operator<<(ostream& stm, const point& pt) 
    { 
        stm << "(" << pt.x << "," << pt.y << ")"; 
        return stm; 
    }

此函数访问pointprivate成员,因此您必须将函数设置为point类的friend

    friend ostream& operator<<(ostream&, const point&);

这样的friend声明必须在point类中声明,但放在publicprivate部分都无关紧要。

将构造函数标记为显式

在某些情况下,您不希望允许将一个类型隐式转换为另一个类型的构造函数的参数。为此,您需要使用explicit限定符标记构造函数。这现在意味着调用构造函数的唯一方法是使用括号语法:显式调用构造函数。在下面的代码中,您不能将double隐式转换为mytype的对象:

    class mytype  
    { 
    public: 
        explicit mytype(double x); 
    };

现在,如果要使用double参数创建对象,则必须显式调用构造函数:

    mytype t1 = 10.0; // will not compile, cannot convert 
    mytype t2(10.0);  // OK

销毁对象

当对象被销毁时,将调用一个名为析构函数的特殊方法。该方法的名称以~符号为前缀,不返回值。

如果对象是自动变量,位于堆栈上,那么当变量超出范围时,它将被销毁。当按值传递对象时,在被调用的函数堆栈上创建一个副本,并且当被调用的函数完成时,对象将被销毁。此外,函数的完成方式并不重要,无论是显式调用return还是达到最终的大括号,或者抛出异常;在所有这些情况下,都会调用析构函数。如果在函数中有多个对象,则在相同作用域中对象的构造顺序相反时调用析构函数。如果创建一个对象数组,则在声明数组的语句中为数组中的每个对象调用默认构造函数,并且当数组超出范围时,将销毁所有对象--并且调用每个对象的析构函数。

以下是一些示例,对于类mytype

    void f(mytype t) // copy created 
    { 
        // use t 
    }   // t destroyed 

    void g() 
    { 
        mytype t1; 
        f(t1); 
        if (true) 
        { 
            mytype t2; 
        }   // t2 destroyed 

        mytype arr[4]; 
    }  // 4 objects in arr destroyed in reverse order to creation 
       // t1 destroyed

当您返回一个对象时,会发生有趣的操作。以下注释是您所期望的:

    mytype get_object() 
    { 
        mytype t;               // default constructor creates t 
        return t;               // copy constructor creates a temporary 
    }                           // t destroyed 

    void h() 
    { 
        test tt = get_object(); // copy constructor creates tt 
    }                           // temporary destroyed, tt destroyed

实际上,这个过程更加简洁。在调试版本中,编译器将看到在get_object函数返回时创建的临时对象是将用作变量tt的对象,因此在get_object函数的返回值上不会有额外的复制。函数实际上看起来是这样的:

    void h() 
    { 
        mytype tt = get_object();  
    }   // tt destroyed

然而,编译器能够进一步优化代码。在发布版本中(启用了优化),临时对象将不会被创建,调用函数中的对象tt将是在get_object中创建的实际对象t

当您显式删除在自由存储器上分配的对象的指针时,对象将被销毁。在这种情况下,对析构函数的调用是确定性的:当您的代码调用delete时,它会被调用。同样,对于相同的类mytype,情况如下:

    mytype *get_object() 
    { 
        return new mytype; // default constructor called 
    } 

    void f() 
    { 
        mytype *p = get_object(); 
        // use p 
        delete p;        // object destroyed 
    }

有时您希望使用删除对象的确定性方面(可能会忘记调用delete而存在潜在危险),有时您更希望确保对象在适当的时间被销毁(尽管可能会在以后的时间更晚)。

如果类中的数据成员是具有析构函数的自定义类型,那么当包含对象被销毁时,也会调用包含对象上的析构函数。尽管如此,请注意,这仅适用于对象是类成员的情况。如果类成员是指向自由存储器中对象的指针,则必须在包含对象的析构函数中显式删除指针。但是,您需要知道指针指向的对象在哪里,因为如果它不在自由存储器中,或者对象被其他对象使用,调用delete将会导致问题。

分配对象

当将已创建的对象分配给另一个对象的值时,将调用赋值运算符。默认情况下,您将获得一个复制赋值运算符,它将复制所有数据成员。这不一定是您想要的,特别是如果对象具有指针数据成员,那么您更有可能希望进行深层复制并复制指向的数据而不是指针的值(在后一种情况下,两个对象将指向相同的数据)。

如果定义了复制构造函数,您仍将获得默认的复制赋值运算符;然而,如果您认为编写自己的复制构造函数很重要,那么您应该提供自定义的复制赋值运算符。 (同样,如果定义了复制赋值运算符,除非定义它,否则将获得默认的复制构造函数。)

复制赋值运算符通常是类的public成员,并且它接受一个用于提供赋值值的const引用对象。赋值运算符的语义是可以链接它们,因此,例如,这段代码调用了两个对象的赋值运算符:

    buffer a, b, c;              // default constructors called 
    // do something with them 
    a = b = c;                   // make them all the same value 
    a.operator=(b.operator=(c)); // make them all the same value

最后两行做了同样的事情,但显然第一个更易读。为了启用这些语义,赋值运算符必须返回一个已经被赋值的对象的引用。因此,类buffer将具有以下方法:

    class buffer 
    { 
        // data members 
    public: 
        buffer(const buffer&);            // copy constructor 
        buffer& operator=(const buffer&); // copy assignment 
    };

尽管复制构造函数和复制赋值方法看起来做了类似的事情,但有一个关键的区别。复制构造函数创建了一个在调用之前不存在的新对象。调用代码知道,如果构造失败,那么将会引发异常。而赋值时,两个对象已经存在,所以你是将一个对象的值复制到另一个对象。这应该被视为一个原子操作,并且应该执行所有的复制;赋值在中途失败,导致一个对象是两个对象的一部分是不可接受的。此外,在构造中,只有在构造成功后对象才存在,因此复制构造不能在对象本身上发生,但是代码将对象分配给自身是完全合法的(尽管毫无意义)。复制赋值需要检查这种情况并采取适当的行动。

有各种策略可以做到这一点,一个常见的策略称为复制和交换惯用法,因为它使用标记为noexcept的标准库swap函数,并且不会引发异常。这种惯用法涉及创建赋值右侧对象的临时副本,然后交换其数据成员与左侧对象的数据成员。

移动语义

C++11 通过移动构造函数和移动赋值运算符提供了移动语义,当临时对象被用于创建另一个对象或被赋值给现有对象时,这些函数将被调用。在这两种情况下,由于临时对象不会在语句之后存在,临时对象的内容可以移动到另一个对象,使临时对象处于无效状态。编译器将通过默认操作从临时对象移动数据到新创建的(或分配给)对象来为您创建这些函数。

您可以编写自己的版本,并且为了指示移动语义,这些版本有一个右值引用的参数(&&)。

如果您希望编译器为您提供这些方法的默认版本,可以在类声明中提供带有=default后缀的原型。在大多数情况下,这是自说明的,而不是一个要求,但如果您正在编写 POD 类,您必须使用这些函数的默认版本,否则is_pod将不返回true

如果您只想使用移动而不使用复制(例如文件句柄类),那么可以删除复制函数:

    class mytype 
    { 
        int *p; 
    public: 
        mytype(const mytype&) = delete;             // copy constructor 
        mytype& operator= (const mytype&) = delete; // copy assignment 
        mytype&(mytype&&);                          // move constructor 
        mytype& operator=(mytype&&);                // move assignment 
    };

这个类有一个指针数据成员,并允许移动语义,在这种情况下,将调用移动构造函数并传递一个临时对象的引用。由于对象是临时的,它在移动构造函数调用后将不会存在。这意味着新对象可以移动临时对象的状态到自身:

    mytype::mytype(mytype&& tmp) 
    { 
        this->p = tmp.p; 
        tmp.p = nullptr; 
    }

移动构造函数将临时对象的指针赋值为nullptr,以便任何为类定义的析构函数不会尝试删除指针。

声明静态成员

您可以声明类的成员——数据成员或方法——为static。在某些方面,这类似于在文件范围声明静态关键字的自动变量和函数的使用方式,但是当在类成员上使用此关键字时,有一些重要且不同的属性。

定义静态成员

当您在类成员上使用static时,这意味着该项与类相关,而不是与特定实例相关。对于数据成员来说,这意味着所有实例共享一个数据项。同样,static方法不附加到对象,它不是__thiscall,也没有this指针。

static方法是类的命名空间的一部分,因此它可以为类创建对象并访问它们的private成员。static方法默认具有__cdecl调用约定,但如果愿意,可以将其声明为__stdcall。这意味着您可以编写一个在类中使用的方法来初始化许多库使用的 C 样式指针。请注意,static函数不能调用类上的非静态方法,因为非静态方法需要this指针,但非静态方法可以调用static方法。

非静态方法通过对象调用,可以使用点运算符(对于类实例)或->运算符(对于对象指针)。static方法不需要关联对象,但可以通过对象调用。这给了调用static方法的两种方式,通过对象或通过class名称:

    class mytype 
    { 
    public: 
        static void f(){} 
        void g(){ f(); } 
    };

在这里,类定义了一个名为fstatic方法和一个名为g的非静态方法。非静态方法g可以调用static方法,但static方法f不能调用非静态方法。由于static方法fpublic,类外的代码可以调用它:

    mytype c; 
    c.g();       // call the nonstatic method 
    c.f();       // can also call the static method thru an object 
    mytype::f(); // call static method without an object

尽管可以通过对象调用static函数,但您根本不需要创建任何对象来调用它。

静态数据成员需要更多的工作,因为当您使用static时,它表示数据成员不是对象的一部分,通常在创建对象时分配数据成员。您必须在类外定义static数据成员:

    class mytype 
    { 
    public: 
        static int i; 
        static void incr() { i++; } 
    }; 

    // in a source file 
    int mytype::i = 42;

数据成员在类外部定义在文件范围内。它使用class名称命名,但请注意,它也必须使用类型进行定义。在这种情况下,数据成员使用一个值进行初始化;如果不这样做,那么在第一次使用变量时,它将具有类型的默认值(在这种情况下为零)。如果选择在头文件中声明类(这是常见的做法),则static数据成员的定义必须在源文件中。

您还可以在方法中声明一个static变量。在这种情况下,该值在所有对象的方法调用中保持不变,因此具有与static class成员相同的效果,但您不必在类外定义变量的问题。

使用静态和全局对象

全局函数中的static变量将在首次调用函数之前创建。同样,作为类成员的static对象将在首次访问之前初始化。

静态和全局对象在调用main函数之前构造,并在main函数完成后销毁。这种初始化顺序存在一些问题。C++标准规定,在源文件中定义的static和全局对象的初始化将在使用该源文件中定义的任何函数或对象之前发生,如果源文件中有几个全局对象,则它们将按照定义的顺序进行初始化。问题在于如果有几个源文件中都有static对象。无法保证这些对象的初始化顺序。如果一个static对象依赖于另一个static对象,那么就会出现问题,因为无法保证依赖对象将在其依赖对象之后创建。

命名构造函数

这是public static方法的一个应用。这个想法是,由于static方法是class的一个成员,这意味着它可以访问class实例的private成员,所以这样一个方法可以创建一个对象,执行一些额外的初始化,然后将对象返回给调用者。这是一个工厂方法。到目前为止使用的point类是使用笛卡尔坐标构建的,但我们也可以基于极坐标创建一个点,其中(x, y)笛卡尔坐标可以计算为:

    x = r * cos(theta) 
    y = r * sin(theta)

这里r是到点的向量的长度,theta是这个向量逆时针到 x 轴的角度。point类已经有一个接受两个double值的构造函数,所以我们不能用它来传递极坐标;相反,我们可以使用一个static方法作为命名构造函数

    class point 
    { 
        double x; double y; 
    public: 
        point(double x, double y) : x(x), y(y){} 
        static point polar(double r, double th) 
        { 
            return point(r * cos(th), r * sin(th)); 
        } 
    };

该方法可以这样调用:

    const double pi = 3.141529; 
    const double root2 = sqrt(2); 
    point p11 = point::polar(root2, pi/4);

对象p11是具有笛卡尔坐标(1,1)的point。在这个例子中,polar方法调用了一个public构造函数,但它可以访问私有成员,所以同样的方法也可以写成(效率较低):

    point point::polar(double r, double th) 
    { 
        point pt; 
        pt.x = r * cos(th); 
        pt.y = r * sin(th); 
        return pt; 
    }

嵌套类

你可以在一个类中定义一个类。如果嵌套类声明为public,那么你可以在容器类中创建对象并将它们返回给外部代码。然而,通常情况下,你会想要声明一个被类使用并且应该是private的类。以下声明了一个public的嵌套类:

    class outer 
    { 
    public: 
        class inner  
        { 
        public: 
            void f(); 
        }; 

        inner g() { return inner(); } 
    }; 

    void outer::inner::f() 
    { 
         // do something 
    }

嵌套类的名称前缀是包含类的名称。

访问 const 对象

到目前为止,你已经看到了许多使用const的例子,也许最常见的是当它作为函数参数应用于引用时,以指示编译器函数只对对象具有只读访问权限。这样的const引用用于通过引用传递对象,以避免通过值传递对象时会发生的复制开销。class上的方法可以访问对象数据成员,并且可能会改变它们,所以如果你通过const引用传递一个对象,编译器只允许引用调用不改变对象的方法。之前定义的point类有两个访问器来访问类中的数据:

    class point 
    { 
        double x; double y; 
    public: 
        double get_x() { return x; } 
        double get_y() { return y: } 
    };

如果你定义一个函数,它接受一个对const的引用,并尝试调用这些访问器,你将会从编译器得到一个错误:

    void print_point(const point& p) 
    { 
        cout << "(" << p.get_x() << "," << p.get_y() << ")" << endl; 
    }

编译器的错误有点模糊:

cannot convert 'this' pointer from 'const point' to 'point &'

这条消息是编译器抱怨对象是const,它是不可变的,并且它不知道这些方法是否会保持对象的状态。解决方法很简单--在不改变对象状态的方法中添加const关键字,就像这样:

    double get_x() const { return x; } 
    double get_y() const { return y: }

这实际上意味着this指针是constconst关键字是函数原型的一部分,所以该方法可以在此上进行重载。你可以有一个方法,当它在一个const对象上调用时被调用,另一个方法在一个非const对象上被调用。这使你能够实现写时复制模式,例如,一个const方法会返回对数据的只读访问,而非const方法会返回可写的数据的副本

当然,标记为const的方法不能改变数据成员,甚至是暂时的。因此,这样的方法只能调用const方法。也许有一些罕见的情况,一个数据成员被设计为通过const对象进行更改;在这种情况下,成员的声明会标记为mutable关键字。

使用指针的对象

对象可以在自由存储器上创建,并通过类型指针访问。这样做更加灵活,因为将指针传递给函数是高效的,并且你可以明确确定对象的生命周期,因为对象是通过调用new创建的,并通过调用delete销毁的。

获取对象成员的指针

如果您需要通过实例访问类数据成员的地址(假设数据成员是public),您只需使用&运算符:

    struct point { double x; double y; }; 
    point p { 10.0, 10.0 }; 
    int *pp = &p.x;

在这种情况下,struct用于声明point,以便成员默认为public。第二行使用初始化列表构造了一个具有两个值的point对象,然后最后一行获取了一个数据成员的指针。当然,在对象被销毁后,指针不能再使用。数据成员被分配在内存中(在这种情况下是在堆栈上),因此地址运算符只是获取指向该内存的指针。

函数指针是一个不同的情况。无论创建了多少个class的实例,内存中只会有一个方法的副本,但是因为方法是使用__thiscall调用约定(带有隐藏的this参数)调用的,所以您必须有一个函数指针,可以用一个指向对象的指针来初始化,以提供this指针。考虑这个class

    class cartesian_vector 
    { 
    public: 
        // other items 
        double get_magnitude() const 
        { 
            return std::sqrt((this->x * this->x) + (this->y * this->y)); 
        }  
    };

我们可以像这样定义一个指向get_magnitude方法的函数指针:

    double (cartesian_vector::*fn)() const = nullptr; 
    fn = &cartesian_vector::get_magnitude;

第一行声明一个函数指针。这类似于 C 函数指针声明,只是指针类型中包含了class名称。这是必需的,以便编译器知道它必须在通过此指针调用时提供this指针。第二行获取方法的指针。请注意,没有涉及任何对象。您不是获取一个对象上的方法的函数指针;您是获取一个必须通过对象调用的class上的方法的指针。要通过此指针调用方法,您需要在对象上使用成员运算符.*

    cartesian_vector vec(1.0, 1.0); 
    double mag = (vec.*fn)();

第一行创建一个对象,第二行调用方法。成员运算符的指针表示在左侧的对象上调用右侧的函数指针。在调用方法时,左侧对象的地址用于this指针。由于这是一个方法,我们需要提供参数列表,在这种情况下为空(如果您有参数,它们将在此语句右侧的括号中)。如果您有一个对象指针,那么语法是类似的,但是您使用->*指向成员运算符:

    cartesian_vector *pvec = new cartesian_vector(1.0, 1.0); 
    double mag = (pvec->*fn)(); 
    delete pvec;

运算符重载

类型的一个行为是您可以应用于它的操作。C允许您重载 C运算符作为类的一部分,以便清楚地表明运算符是作用于该类型的。这意味着对于一元运算符,成员方法不应该有参数,对于二元运算符,您只需要一个参数,因为当前对象将位于运算符的左侧,因此方法参数是右侧的项目。以下表总结了如何实现一元和二元运算符,以及四个异常:

表达式 名称 成员方法 非成员函数
+a/-a 前缀一元 运算符() 运算符(a)
a, b 二元 运算符(b) 运算符(a,b)
a+/a- 后缀一元 运算符(0) 运算符(a,0)
a=b 赋值 运算符=(b)
a(b) 函数调用 运算符()(b)
a[b] 索引 运算符
a-> 指针访问 运算符->()

这里的■符号用于表示表中提到的四个运算符之外的任何可接受的一元或二元运算符。

没有严格的规则规定运算符应该返回什么,但是如果自定义类型的运算符的行为类似于内置类型的运算符,那将会有所帮助。还必须有一些一致性。如果您实现+运算符来将两个对象相加,那么+=运算符应该使用相同的加法操作。同样,您可以认为加法操作也将决定减法操作应该是什么样子,因此--=运算符。同样,如果您想定义<运算符,那么您应该定义<=>>===!=

标准库的算法(例如sort)只会期望在自定义类型上定义<运算符。

表格显示,你可以将几乎所有的运算符实现为自定义类型类的成员或全局函数(除了那四个必须是成员方法的例外)。一般来说,最好将运算符作为类的一部分实现,因为它保持了封装性:成员函数可以访问类的非公共成员。

一元运算符的一个例子是一元负运算符。这通常不会改变一个对象,而是返回一个对象的负值。对于我们的point class,这意味着使两个坐标都变为负数,这相当于在一条线y = -x上对笛卡尔点进行镜像:

    // inline in point 
    point operator-() const 
    { 
        return point(-this->x, -this->y); 
    }

运算符声明为const,因为很明显运算符不会改变对象,因此可以安全地在const对象上调用。运算符可以这样调用:

    point p1(-1,1); 
    point p2 = -p1; // p2 is (1,-1)

要理解为什么我们要这样实现运算符,请回顾一下当应用于内置类型时一元运算符会做什么。这里的第二个语句,int i, j=0; i = -j;,只会改变i,不会改变j,所以成员operator-不应该影响对象的值。

二元负运算符有不同的含义。首先,它有两个操作数,其次,在这个例子中,结果与操作数的类型不同,因为结果是一个向量,通过从一个点中减去另一个点来指示一个方向。假设cartesian_vector已经定义了一个具有两个参数的构造函数,那么我们可以写成:

    cartesian_vector point::operator-(point& rhs) const 
    { 
        return cartesian_vector(this->x - rhs.x, this->y - rhs.y); 
    }

增量和减量运算符有特殊的语法,因为它们是可以前置或后置的一元运算符,并且会改变它们所应用的对象。两个运算符之间的主要区别在于后置运算符返回增量/减量操作之前对象的值,因此需要创建一个临时对象。因此,前置运算符几乎总是比后置运算符具有更好的性能。在类定义中,为了区分这两者,前置运算符没有参数,后置运算符有一个虚拟参数(在前面的表中给出了 0)。对于一个类mytype,如下所示:

    class mytype  
    { 
    public: 
        mytype& operator++() 
        {  
            // do actual increment 
            return *this; 
        } 
        mytype operator++(int) 
        { 
            mytype tmp(*this); 
            operator++(); // call the prefix code 
            return tmp; 
        } 
    };

实际的增量代码是由前置运算符实现的,并且后置运算符通过显式调用该方法使用这个逻辑。

定义函数类

一个函数对象是一个实现了()运算符的类。这意味着你可以使用与函数相同的语法调用一个对象。考虑这个:

    class factor 
    { 
        double f = 1.0; 
    public: 
        factor(double d) : f(d) {} 
        double operator()(double x) const { return f * x; }  
    };

这段代码可以这样调用:

    factor threeTimes(3);        // create the functor object 
    double ten = 10.0; 
    double d1 = threeTimes(ten); // calls operator(double) 
    double d2 = threeTimes(d1);  // calls operator(double)

这段代码表明,函数对象不仅提供了一些行为(在这种情况下,在参数上执行一个动作),而且还可以有一个状态。前两行是通过对象上的operator()方法调用的:

    double d2 = threeTimes.operator()(d1);

看语法。函数对象被调用,就好像它是这样声明的函数:

    double multiply_by_3(double d) 
    { 
        return 3 * d;  
    }

想象一下,你想传递一个指向函数的指针--也许你希望函数的行为被外部代码改变。为了能够使用函数对象或方法指针,你需要重载你的函数:

    void print_value(double d, factor& fn); 
    void print_value(double d, double(*fn)(double));

第一个接受一个函数对象的引用。第二个有一个 C 类型的函数指针(你可以传递一个指向multiply_by_3的指针),并且相当难以阅读。在两种情况下,fn参数在实现代码中以相同的方式被调用,但你需要声明两个函数,因为它们是不同的类型。现在,考虑函数模板的魔力:

    template<typename Fn> 
    void print_value(double d, Fn& fn) 
    { 
        double ret = fn(d); 
        cout << ret << endl; 
    }

这是通用代码;Fn类型可以是一个 C 函数指针或一个函数对象class,编译器将生成适当的代码。

这段代码可以通过传递一个指向全局函数的函数指针来调用,该函数将具有__cdecl调用约定,或者传递一个函数对象,其中将调用operator()运算符,该运算符具有__thiscall调用约定。

这只是一个实现细节,但这意味着你可以编写一个通用函数,可以接受 C 风格的函数指针或函数对象作为参数。C++标准库使用了这个魔法,这意味着它提供的算法可以用全局函数函数对象lambda 表达式来调用。

标准库算法使用三种类型的函数类、生成器和一元和二元函数;也就是说,没有参数、一个参数或两个参数的函数。此外,标准库调用返回bool的函数对象(一元或二元)谓词。文档会告诉你是否需要谓词、一元或二元函数。旧版本的标准库需要知道函数对象的返回值和参数(如果有的话)的类型,因此,函数对象类必须基于标准类unary_functionbinary_function(通过继承,在下一章中解释)。在 C++11 中,这个要求已经被移除,因此没有必要使用这些类。

在某些情况下,当需要一元函数时,你可能希望使用二元函数。例如,标准库定义了greater类,当作为函数对象使用时,它接受两个参数和一个bool来确定第一个参数是否大于第二个参数,使用两个参数的类型定义的operator>。这将用于需要二元函数的函数,因此函数将比较两个值;例如:

    template<typename Fn>  
    int compare_vals(vector<double> d1, vector<double> d2, Fn compare) 
    { 
        if (d1.size() > d2.size()) return -1; // error 
        int c = 0; 
        for (size_t i = 0; i < d1.size(); ++i) 
        { 
            if (compare(d1[i], d2[i])) c++; 
        } 
        return c; 
    }

这需要两个集合,并使用作为最后一个参数传递的函数对象比较相应的项目。可以这样调用它:

    vector<double> d1{ 1.0, 2.0, 3.0, 4.0 }; 
    vector<double> d2{ 1.0, 1.0, 2.0, 5.0 }; 
    int c = compare_vals(d1, d2, greater<double>());

greater函数对象类在<functional>头文件中定义,使用为类型定义的operator>比较两个数字。如果你想要比较容器中的项目和一个固定值;也就是说,当函数对象的operator()(double, double)方法被调用时,一个参数总是有一个固定的值?一种选择是定义一个有状态的函数对象类(如前面所示),以便固定值是函数对象的成员。另一种方法是用固定值填充另一个vector,并继续比较两个vector(对于大的vector来说,这可能会变得非常昂贵)。

另一种方法是重用函数对象类,但是绑定一个值到它的一个参数上。compare_vals函数的一个版本可以这样写,只接收一个vector

    template<typename Fn>  
    int compare_vals(vector<double> d, Fn compare) 
    { 
        int c = 0; 
        for (size_t i = 0; i < d.size(); ++i) 
        { 
            if (compare(d[i]) c++; 
        } 
        return c; 
    }

代码被编写为只调用函数对象参数的一个值,因为假设函数对象包含另一个要比较的值。这是通过将函数对象类绑定到参数来实现的:

    using namespace::std::placeholders; 
    int c = compare_vals(d1, bind(greater<double>(), _1, 2.0));

bind函数是可变参数的。第一个参数是函数对象,后面是将传递给函数对象的operator()方法的参数。compare_vals函数接收一个binder对象,将函数对象绑定到值上。在compare_vals函数中,对compare(d[i])中的函数对象的调用实际上是对绑定对象的operator()方法的调用,这个方法将参数d[i]和绑定的值转发给函数对象的operator()方法。

在调用bind时,如果提供了实际值(这里是2.0),那么该值将传递给函数对象在调用函数对象时的位置(这里,2.0传递给第二个参数)。如果使用下划线前缀的符号,则是占位符std::placeholders命名空间中定义了 20 个这样的符号(_1_20)。占位符的意思是“使用在这个位置传递的值来调用绑定器对象的operator()方法,并将其传递给函数对象调用operator()方法中由占位符指示的位置”。因此,这个调用中的占位符意味着“将从调用绑定器中传递的第一个参数传递给greater函数对象的operator()的第一个参数”。

前面的代码将vector中的每个项目与2.0进行比较,并将大于2.0的项目计数。您可以这样调用它:

    int c = compare(d1, bind(greater<double>(), 2.0, _1));

参数列表被交换,这意味着2.0vector中的每个项目进行比较,并且函数将计算2.0大于项目的次数。

bind函数和占位符是 C++11 中的新功能。在之前的版本中,您可以使用bind1stbind2nd函数将值绑定到函数对象的第一个或第二个参数。

定义转换运算符

我们已经看到,如果自定义类型具有接受要转换的类型的构造函数,则可以使用构造函数将另一种类型转换为自定义类型。您还可以执行另一种方向的转换:将对象转换为另一种类型。为此,您提供一个没有返回类型的带有要转换为的类型名称的运算符。在这种情况下,operator关键字和名称之间需要有一个空格:

    class mytype 
    { 
        int i; 
    public: 
        mytype(int i) : i(i) {} 
        explicit mytype(string s) : i(s.size()) {} 
        operator int () const { return i; } 
    };

这段代码可以将intstring转换为mytype;在后一种情况下,只能通过显式提及构造函数来实现。最后一行允许将对象转换回int

    string s = "hello"; 
    mytype t = mytype(s); // explicit conversion 
    int i = t;            // implicit conversion

您可以将这样的转换运算符设置为explicit,这样它们只会在使用显式转换时被调用。在许多情况下,您可能希望省略此关键字,因为当您想要将资源封装在类中并使用析构函数来自动管理资源时,隐式转换是有用的。

使用转换运算符的另一个示例是从有状态的函数对象返回值。这里的想法是operator()将执行某些操作,并且结果由函数对象维护。问题是,当它们经常作为临时对象创建时,如何获取函数对象的状态?转换运算符可以提供此功能。

例如,当计算平均值时,需要分两个阶段:第一阶段是累积值,第二阶段是通过将其除以项目数来计算平均值。以下函数对象类通过将除法作为转换为double的一部分来执行此操作:

    class averager 
    { 
        double total; 
        int count; 
    public: 
        averager() : total(0), count(0) {} 
        void operator()(double d) { total += d; count += 1; } 
        operator double() const 
        {        
            return (count != 0) ? (total / count) : 
                numeric_limits<double>::signaling_NaN(); 
        } 
    };

可以这样调用:

    vector<double> vals { 100.0, 20.0, 30.0 }; 
    double avg = for_each(vals.begin(), vals.end(), averager());

for_each函数对vector中的每个项目调用函数对象,operator()简单地对传递给它的项目求和并保持计数。有趣的部分是,在for_each函数遍历完vector中的所有项目后,它会返回函数对象,因此会有一个隐式转换为double,这会调用计算平均值的转换运算符。

管理资源

我们已经看到一种需要仔细管理的资源:内存。您使用new分配内存,当您使用完内存后,必须使用delete释放内存。未能释放内存将导致内存泄漏。内存可能是最基本的系统资源,但大多数操作系统还有许多其他资源:文件句柄、图形对象句柄、同步对象、线程和进程。有时拥有这样的资源是独占的,并且会阻止其他代码访问通过该资源访问的资源。因此,重要的是这些资源在某个时刻被释放,通常情况下,它们应该及时释放。

类在这里有所帮助,使用了由 C++的作者 Bjarne Stroustrup 发明的一种称为资源获取即初始化(RAII)的机制。简单地说,资源在对象的构造函数中分配,并在析构函数中释放,这意味着资源的生命周期是对象的生命周期。通常,这样的包装对象是在堆栈上分配的,这意味着无论发生什么,当对象超出范围时,资源都将被释放。

因此,如果对象在循环语句(whilefor)的代码块中声明,则在每次循环结束时,将调用每个对象的析构函数(按创建顺序的相反顺序),并且当循环重复时,对象将被再次创建。无论循环是因为代码块的末尾已经到达,还是因为循环通过调用continue重复。离开代码块的另一种方法是通过调用breakgoto,或者如果代码调用return离开函数。如果代码引发异常(参见第十章,诊断和调试),则在对象超出范围时将调用析构函数,因此如果代码受try块保护,则在调用catch子句之前将调用块中声明的对象的析构函数。如果没有保护块,则在函数堆栈被销毁并传播异常之前将调用析构函数。

编写包装类

在编写包装资源的类时,您必须解决几个问题。构造函数将被用来获取资源,要么使用某个库函数(通常通过某种不透明句柄访问),要么将资源作为参数。这个资源将作为数据成员存储,以便类上的其他方法可以使用它。析构函数将使用库提供的函数释放资源。这是最基本的。此外,您还需要考虑对象将如何被使用。通常,如果可以将实例用作资源句柄,这样的包装类最方便。这意味着您保持相同的编程风格来访问资源,但您不必太担心释放资源。

您应该考虑是否希望能够在包装类和资源句柄之间进行转换。如果允许这样做,这意味着您可能需要考虑克隆资源,以便您不会有两个句柄的副本--一个由类管理,另一个副本可能会被外部代码释放。您还需要考虑是否允许对象被复制或分配,如果是的话,那么您将需要适当地实现复制构造函数、移动构造函数以及复制和移动赋值运算符。

使用智能指针

C++标准库提供了几个类来包装通过指针访问的资源。为了防止内存泄漏,您必须确保在某个时刻释放在自由存储器上分配的内存。智能指针的概念是您将一个实例视为指针,因此您使用*运算符进行解引用以访问它指向的对象,或者使用->运算符访问包装对象的成员。智能指针类将管理其包装的指针的生命周期,并将适当地释放资源。

标准库有三个智能指针类:unique_ptrshared_ptrweak_ptr。每个类以不同的方式处理资源的释放,以及是否以及如何复制指针。

管理独占所有权

unique_ptr类是使用指向它将维护的对象的指针构造的。该类提供了*运算符来访问对象,对包装的指针进行解引用。它还提供了->运算符,因此如果指针是类的指针,您可以通过包装的指针访问成员。

以下在自由存储器上分配一个对象并手动维护其生命周期:

    void f1() 
    { 
       int* p = new int; 
       *p = 42; 
       cout << *p << endl; 
       delete p; 
    }

在这种情况下,您获得了一个指向为int分配的自由存储器上的内存的指针。要访问内存--无论是写入还是读取--您都必须使用*运算符对指针进行解引用。当您完成指针时,必须调用delete来释放内存并将其返回给自由存储器。现在考虑相同的代码,但使用智能指针:

    void f2() 
    { 
       unique_ptr<int> p(new int); 
       *p = 42; 
       cout << *p << endl; 
       delete p.release(); 
    }

两个主要区别是智能指针对象是通过调用接受用作模板参数的指针的构造函数来显式构造的。这种模式强调了资源应该只由智能指针管理的观念。

第二个变化是通过在智能指针对象上调用release方法来释放包装的指针的所有权,以便我们可以显式删除指针。

考虑release方法释放智能指针对指针的所有权。调用此方法后,智能指针不再包装资源。unique_ptr类还有一个get方法,可以访问包装的指针,但智能指针对象仍将保留所有权;不要通过这种方式删除获取的指针

请注意,unique_ptr对象包装一个指针,只是指针。这意味着对象在内存中的大小与它包装的指针相同。到目前为止,智能指针增加了很少的东西,所以让我们看另一种释放资源的方法:

    void f3() 
    { 
       unique_ptr<int> p(new int); 
       *p = 42; 
       cout << *p << endl; 
       p.reset(); 
    }

这是资源的确定性释放,意味着资源在您希望发生时释放,这类似于指针的情况。这里的代码并不是释放资源本身;它允许智能指针使用删除器来执行释放。unique_ptr的默认删除器是一个名为default_delete的函数类,它在包装的指针上调用delete运算符。如果您打算使用确定性销毁,reset是首选方法。您可以通过将自定义函数类的类型作为unique_ptr模板的第二个参数传递来提供自己的删除器:

    template<typename T> struct my_deleter 
    { 
        void operator()(T* ptr)  
        { 
            cout << "deleted the object!" << endl; 
            delete ptr; 
        } 
    };

在您的代码中,您将指定要使用自定义删除器,如下所示:

    unique_ptr<int, my_deleter<int> > p(new int);

在删除指针之前,您可能需要执行额外的清理,或者指针可能是通过new之外的机制获得的,因此您可以使用自定义删除器来确保调用适当的释放函数。请注意,删除器是智能指针类的一部分,因此如果您有两个不同的智能指针以这种方式使用两个不同的删除器,那么智能指针类型是不同的,即使它们包装相同类型的资源。

当你使用自定义删除器时,unique_ptr对象的大小可能比包装的指针更大。如果删除器是一个函数对象,每个智能指针对象都将需要为此分配内存,但如果使用 lambda 表达式,则不需要额外的空间。

当然,你很可能允许智能指针为你管理资源的生命周期,为此你只需允许智能指针对象超出范围即可:

    void f4() 
    { 
       unique_ptr<int> p(new int); 
       *p = 42; 
       cout << *p << endl; 
    } // memory is deleted

由于创建的指针是一个单一对象,这意味着你可以在适当的构造函数上调用new运算符来传递初始化参数。unique_ptr的构造函数接收一个指向已构造对象的指针,之后该类管理对象的生命周期。虽然可以直接通过调用其构造函数创建unique_ptr对象,但无法调用复制构造函数,因此无法在构造过程中使用初始化语法。相反,标准库提供了一个名为make_unique的函数。它有几种重载形式,因此这是创建基于该类的智能指针的首选方式:

    void f5() 
    { 
       unique_ptr<int> p = make_unique<int>(); 
       *p = 42; 
       cout << *p << endl; 
    } // memory is deleted

这段代码将调用包装类型(int)的默认构造函数,但你可以提供参数,这些参数将传递给类型的适当构造函数。例如,对于具有两个参数的构造函数的struct,可以使用以下内容:

    void f6() 
    { 
       unique_ptr<point> p = make_unique<point>(1.0, 1.0); 
       p->x = 42; 
       cout << p->x << "," << p->y << endl; 
    } // memory is deleted

make_unique函数调用分配非默认值的成员的构造函数。->运算符返回一个指针,编译器将通过这个指针访问对象成员。

还有一个用于数组的unique_ptrmake_unique的特化版本。这个版本的unique_ptr的默认删除器将在指针上调用delete[],因此它将删除数组中的每个对象(并调用每个对象的析构函数)。该类实现了一个索引器运算符([]),因此可以访问数组中的每个项目。但是,请注意,没有范围检查,因此,就像内置数组变量一样,可以访问数组末尾之外的位置。没有解引用运算符(*->),因此基于数组的unique_ptr对象只能使用数组语法访问。

make_unique函数有一个重载,允许你传递要创建的数组的大小,但你必须单独初始化每个对象:

    unique_ptr<point[]> points = make_unique<point[]>(4);     
    points[1].x = 10.0; 
    points[1].y = -10.0;

这将创建一个包含四个初始设置为默认值的point对象的数组,并且以下行将第二个点初始化为(10.0, -10.0)的值。几乎总是比使用vectorarray更好来管理对象数组。

早期版本的 C标准库有一个名为auto_ptr的智能指针类。这是一个首次尝试,在大多数情况下都有效,但也有一些限制;例如,auto_ptr对象无法存储在标准库容器中。C11 引入了右值引用和其他语言特性,如移动语义,通过这些特性,unique_ptr对象可以存储在容器中。auto_ptr类仍然可以通过<new>头文件使用,但只是为了让旧代码仍然可以编译。

unique_ptr类的重要一点是它确保指针只有一个副本。这很重要,因为类析构函数将释放资源,因此如果可以复制unique_ptr对象,这将意味着将有多个析构函数尝试释放资源。unique_ptr对象具有独占所有权;实例始终拥有其指向的内容。

您不能复制分配unique_ptr智能指针(复制分配运算符和复制构造函数已被删除),但可以通过转移所有权从源指针到目标指针来移动它们。因此,函数可以返回unique_ptr,因为所有权通过移动语义传递给被分配给函数值的变量。如果智能指针放入容器中,还有另一个移动。

共享所有权

有时您需要共享指针:您可能创建了几个对象,并将指针传递给每个对象的单个对象,以便它们可以调用此对象。通常,当一个对象有指向另一个对象的指针时,该指针代表应在包含对象销毁期间销毁的资源。如果指针被共享,这意味着当其中一个对象删除指针时,所有其他对象中的指针将无效(这称为悬空指针,因为它不再指向对象)。您需要一种机制,使得几个对象可以持有一个指针,直到所有使用该指针的对象都表示它们将不再需要使用它为止,该指针将保持有效。

C++11 提供了shared_ptr类的这个功能。该类在资源上维护引用计数,每个shared_ptr的副本都会增加引用计数。当销毁该资源的一个shared_ptr实例时,它将减少引用计数。引用计数是共享的,因此它意味着非零值表示至少存在一个shared_ptr访问该资源。当最后一个shared_ptr对象将引用计数减少到零时,可以安全释放资源。这意味着引用计数必须以原子方式进行管理,以处理多线程代码。

由于引用计数是共享的,这意味着每个shared_ptr对象都持有指向称为控制块的共享缓冲区的指针,这意味着它持有原始指针和指向控制块的指针,因此每个shared_ptr对象将持有比unique_ptr更多的数据。控制块用于不仅仅是引用计数。

可以创建一个shared_ptr对象来使用自定义删除器(作为构造函数参数传递),并且删除器存储在控制块中。这很重要,因为这意味着自定义删除器不是智能指针类型的一部分,因此封装相同资源类型但使用不同删除器的几个shared_ptr对象仍然是相同类型,并且可以放入该类型的容器中。

您可以从另一个shared_ptr对象创建一个shared_ptr对象,这将使用原始指针和指向控制块的指针初始化新对象,并增加引用计数。

    point* p = new point(1.0, 1.0); 
    shared_ptr<point> sp1(p); // Important, do not use p after this! 
    shared_ptr<point> sp2(sp1); 
    p = nullptr; 
    sp2->x = 2.0; 
    sp1->y = 2.0; 
    sp1.reset(); // get rid of one shared pointer

在这里,第一个共享指针是使用原始指针创建的。这不是使用shared_ptr的推荐方式。第二个共享指针是使用第一个智能指针创建的,所以现在有两个共享指针指向相同的资源(p被赋值为nullptr以防止进一步使用)。在此之后,要么sp1要么sp2可以用来访问相同的资源。在此代码结束时,一个共享指针被重置为nullptr;这意味着sp1不再对资源有引用计数,并且不能使用它来访问资源。然而,您仍然可以使用sp2来访问资源,直到它超出范围,或者您调用reset

在这段代码中,智能指针是从单独的原始指针创建的。由于共享指针现在已经接管了资源的生命周期管理,因此重要的是不再使用原始指针,在这种情况下将其分配为nullptr。最好避免使用原始指针,标准库通过一个名为make_shared的函数实现了这一点,可以像这样使用:

    shared_ptr<point> sp1 = make_shared<point>(1.0,1.0);

该函数将使用new调用创建指定的对象,并且由于它接受可变数量的参数,你可以使用它来调用包装类上的任何构造函数。

你可以从unique_ptr对象创建一个shared_ptr对象,这意味着指针被移动到新对象,并创建了引用计数控制块。由于资源现在是共享的,这意味着不再具有资源的独占所有权,因此unique_ptr对象中的指针将被设置为nullptr。这意味着你可以有一个工厂函数,返回一个包装在unique_ptr对象中的对象的指针,调用代码可以确定是否使用unique_ptr对象来独占访问资源,还是使用shared_ptr对象来共享资源。

对于对象数组,使用shared_ptr没有太大意义;有更好的方法来存储对象的集合(vectorarray)。无论如何,有一个索引运算符([]),默认删除器调用delete,而不是delete[]

处理悬空指针

本书前面我们指出,当删除资源时,应将指针设置为nullptr,并在使用指针之前检查指针是否为nullptr。这样可以避免调用已删除对象的内存指针:悬空指针。

有时会出现悬空指针的情况。例如,对象可能会创建具有指向父对象的后指针对象,以便子对象可以访问父对象。(一个例子是窗口创建子控件;子控件通常可以访问父窗口。)在这种情况下使用共享指针的问题在于,父对象将对每个子控件有一个引用计数,每个子控件对父对象也有一个引用计数,这会创建一个循环依赖。

另一个例子是,如果你有一个观察者对象的容器,希望在事件发生时通过调用每个观察者对象的方法来通知每个观察者对象。维护这个列表可能会很复杂,特别是如果观察者对象可以被删除,因此你必须提供一种方法来从容器中删除对象(其中将有一个shared_ptr引用计数),然后才能完全删除对象。如果你的代码可以简单地向容器中添加一个指向对象的指针,而不维护引用计数,但允许你在使用指针时检查指针是否悬空或指向现有对象,那就更容易了。

这样的指针称为弱指针,C++11 标准库提供了一个名为weak_ptr的类。你不能直接使用weak_ptr对象,也没有解引用运算符。相反,你可以从shared_ptr对象创建一个weak_ptr对象,当你想要访问资源时,你可以从weak_ptr对象创建一个shared_ptr对象。这意味着weak_ptr对象具有相同的原始指针,并且访问相同的控制块,但它不参与引用计数。

创建后,weak_ptr对象将使你能够测试包装指针是指向现有资源还是已被销毁的资源。有两种方法可以做到这一点:要么调用成员函数expired,要么尝试从weak_ptr创建一个shared_ptr。如果你正在维护一组weak_ptr对象,你可能决定定期遍历集合,对每个对象调用expired,如果该方法返回true,则从集合中删除该对象。由于weak_ptr对象可以访问原始shared_ptr对象创建的控制块,它可以测试引用计数是否为零。

测试weak_ptr对象是否悬空的第二种方法是从中创建一个shared_ptr对象。有两种选择。你可以通过将弱指针传递给其构造函数来创建shared_ptr对象,如果指针已经过期,构造函数将抛出一个bad_weak_ptr异常。另一种方法是在弱指针上调用lock方法,如果弱指针已经过期,那么shared_ptr对象将被分配为nullptr,你可以测试这一点。这三种方法在这里显示:

    shared_ptr<point> sp1 = make_shared<point>(1.0,1.0); 
    weak_ptr<point> wp(sp1); 

    // code that may call sp1.reset() or may not 

    if (!wp.expired())  { /* can use the resource */} 

    shared_ptr<point> sp2 = wp.lock(); 
    if (sp2 != nullptr) { /* can use the resource */} 

    try 
    { 
        shared_ptr<point> sp3(wp); 
        // use the pointer 
    } 
    catch(bad_weak_ptr& e) 
    { 
        // dangling weak pointer 
    }

由于弱指针不会改变资源的引用计数,这意味着你可以用它作为回指针来打破循环依赖(尽管通常更合理的是使用原始指针,因为子对象不能没有父对象而存在)。

模板

类可以被模板化,这意味着你可以编写通用代码,编译器将生成一个使用你的代码类型的类。参数可以是类型、常量整数值,或者可变版本(零个或多个参数,由使用类的代码提供)。例如:

    template <int N, typename T> class simple_array 
    { 
        T data[N]; 
    public: 
        const T* begin() const { return data; } 
        const T* end() const { return data + N; } 
        int size() const { return N; } 

        T& operator[](int idx)  
        { 
            if (idx < 0 || idx >= N) 
                throw range_error("Range 0 to " + to_string(N)); 
            return data[idx]; 
        }  
    };

这是一个非常简单的数组类,它定义了基本的迭代器函数和索引运算符,这样你就可以像这样调用它:

    simple_array<4, int> four; 
    four[0] = 10; four[1] = 20; four[2] = 30; four[3] = 40; 
    for(int i : four) cout << i << " "; // 10 20 30 40 
    cout << endl; 
    four[4] = -99;            // throws a range_error exception

如果你选择在class声明之外定义一个函数,那么你需要将模板及其参数作为class名称的一部分提供:

    template<int N, typename T> 
    T& simple_array<N,T>::operator[](int idx) 
    { 
        if (idx < 0 || idx >= N) 
            throw range_error("Range 0 to " + to_string(N)); 
        return data[idx]; 
    }

你也可以为模板参数设置默认值:

    template<int N, typename T=int> class simple_array 
    { 
        // same as before 
    };

如果你认为应该为模板参数提供特定的实现,那么你可以将该版本的代码作为模板的特化提供:

    template<int N> class simple_array<N, char> 
    { 
        char data[N]; 
    public: 
        simple_array<N, char>(const char* str)  
        {  
            strncpy(data, str, N);  
        } 
        int size() const { return N; } 
        char& operator[](int idx) 
        { 
            if (idx < 0 || idx >= N) 
                throw range_error("Range 0 to " + to_string(N)); 
            return data[idx]; 
        } 
        operator const char*() const { return data; } 
    };

请注意,使用特化时,你不会从完全模板化的类中获得任何代码;你必须实现你想要提供的所有方法,并且,如本例所示,特化相关但在完全模板化类上不可用的方法。这个例子是一个部分特化,意味着它只是针对一个参数(T,数据类型)进行了特化。这个类将用于声明为类型simple_array<n, char>的变量,其中n是一个整数。你可以自由地拥有一个完全特化的模板,在这种情况下,它将是一个特定大小和指定类型的特化:

    template<> class simple_array<256, char> 
    { 
        char data[256]; 
    public: 
        // etc 
    };

在这种情况下可能没有用,但是想法是对需要 256 个字符的变量有特殊的代码。

使用类

资源获取即初始化技术对于管理由其他库提供的资源非常有用,比如 C 运行时库或 Windows SDK。它简化了你的代码,因为你不必考虑资源句柄何时超出范围,并在每个点提供清理代码。如果清理代码很复杂,在 C 代码中通常会看到它放在函数的末尾,函数中的每个退出点都会有一个goto跳转到该代码。这会导致混乱的代码。在这个例子中,我们将用一个类来包装 C 文件函数,这样文件句柄的生命周期将自动维护。

C 运行时的_findfirst_findnext函数允许你搜索与模式匹配的文件或目录(包括通配符)。_findfirst函数返回一个intptr_t,这与搜索相关,然后将其传递给_findnext函数以获取后续值。这个intptr_t是一个不透明指针,指向 C 运行时为搜索维护的资源,所以当你完成搜索时,你必须调用_findclose来清理与之相关的任何资源。为了防止内存泄漏,调用_findclose非常重要。

Beginning_C++文件夹下,创建一个名为Chapter_06的文件夹。在 Visual C中,创建一个新的 C源文件,将其保存到Chapter_06文件夹中,并将其命名为search.cpp。该应用程序将使用标准库控制台和字符串,并且将使用 C 运行时文件函数,因此在文件顶部添加这些行:

    #include <iostream> 
    #include <string> 
    #include <io.h> 
    using namespace std;

应用程序将使用文件搜索模式调用,并使用 C 函数搜索文件,因此您需要一个具有参数的main函数。在文件底部添加以下内容:

    void usage() 
    { 
        cout << "usage: search pattern" << endl; 
        cout << "pattern is the file or folder to search for " 
             << "with or without wildcards * and ?" << endl; 
    } 

    int main(int argc, char* argv[]) 
    { 
        if (argc < 2) 
        { 
            usage(); 
            return 1; 
        } 
    }

首先要创建一个包装类来管理搜索句柄的资源。在使用函数上方,添加一个名为search_handle的类:

    class search_handle 
    { 
        intptr_t handle; 
    public: 
        search_handle() : handle(-1) {} 
        search_handle(intptr_t p) : handle(p) {} 
        void operator=(intptr_t p) { handle = p; } 
        void close()  
        { if (handle != -1) _findclose(handle); handle = 0; } 
        ~search_handle() { close(); } 
    };

此类有一个单独的函数来释放句柄。这样,该类的用户可以尽快释放包装资源。如果对象用于可能引发异常的代码中,则close方法不会直接调用,而是将调用析构函数。包装对象可以使用intptr_t值创建。如果此值为-1,则句柄无效,因此仅当句柄不具有此值时,close方法才会调用_findclose

我们希望该类的对象具有句柄的独占所有权,因此通过将以下内容放入类的public部分来删除复制构造函数和复制赋值:

    void operator=(intptr_t p) { handle = p; } 
 search_handle(search_handle& h) = delete; void operator=(search_handle& h) = delete;

如果对象被移动,那么现有对象中的任何句柄都必须被释放,因此在刚刚添加的行之后添加以下内容:

    search_handle(search_handle&& h)  { close(); handle = h.handle; } 
    void operator=(search_handle&& h) { close(); handle = h.handle; }

包装类将通过调用_findfirst分配,并将传递给调用_findnext,因此包装类需要两个运算符:一个用于转换为intptr_t,以便可以在需要intptr_t的任何地方使用此类的对象,另一个用于在需要bool时使用对象。将这些添加到类的public部分:

    operator bool() const { return (handle != -1); } 
    operator intptr_t() const { return handle; }

将转换为bool允许您编写如下代码:

    search_handle handle = /* initialize it */; 
    if (!handle) { /* handle is invalid */ }

如果有一个返回指针的转换运算符,那么编译器将优先调用它,而不是转换为bool

您应该能够编译此代码(记得使用/EHsc开关)以确认没有拼写错误。

接下来,编写一个包装类来执行搜索。在search_handle类下方,添加一个file_search类:

    class file_search 
    { 
        search_handle handle; 
        string search; 
    public: 
        file_search(const char* str) : search(str) {} 
        file_search(const string& str) : search(str) {} 
    };

该类是使用搜索条件创建的,我们可以选择传递 C 或 C++字符串。该类有一个search_handle数据成员,并且,由于默认析构函数将调用成员对象的析构函数,因此我们不需要自己提供析构函数。但是,我们将添加一个close方法,以便用户可以显式释放资源。此外,为了使该类的用户能够确定搜索路径,我们需要一个访问器。在类的底部添加以下内容:

    const char* path() const { return search.c_str(); } 
    void close() { handle.close(); }

我们不希望复制file_search对象的实例,因为这意味着搜索句柄的两个副本。您可以删除复制构造函数和赋值运算符,但没有必要。尝试这样做:在main函数中,添加此测试代码(位置无关紧要):

    file_search f1(""); 
    file_search f2 = f1;

编译代码。您将收到一个错误和一个解释:

 error C2280: 'file_search::file_search(file_search &)': attempting to reference a deleted function
 note: compiler has generated 'file_search::file_search' here

没有复制构造函数,编译器将生成一个(这是第二行)。第一行有点奇怪,因为它说您正在尝试调用编译器生成的已删除方法!实际上,错误是说生成的复制构造函数正在尝试复制handle数据成员和已删除的search_handle复制构造函数。因此,您受到保护,防止复制file_search对象而无需添加任何其他代码。删除刚添加的测试行。

接下来在main函数的底部添加以下行。这将创建一个file_search对象并将信息打印到控制台。

    file_search files(argv[1]); 
    cout << "searching for " << files.path() << endl;

然后需要添加代码来执行搜索。这里使用的模式将是一个具有输出参数并返回bool的方法。如果方法调用成功,则找到的文件将在输出参数中返回,并且该方法将返回true。如果调用失败,则输出参数将保持不变,并且该方法将返回false。在file_search类的public部分中,添加此函数:

    bool next(string& ret) 
    { 
        _finddata_t find{}; 
        if (!handle) 
        { 
            handle = _findfirst(search.c_str(), &find); 
            if (!handle) return false; 
        } 
        else 
        { 
            if (-1 == _findnext(handle, &find)) return false; 
        } 

        ret = find.name; 
        return true; 
    }

如果这是对该方法的第一次调用,则handle将无效,因此将调用_findfirst。这将使用搜索结果填充_finddata_t结构并返回一个intptr_t值。search_handle对象的数据成员被赋予此函数返回的值,如果_findfirst返回-1,则该方法返回false。如果调用成功,则使用_finddata_t结构中的 C 字符串指针初始化输出参数(指向string的引用)。

如果有更多与模式匹配的文件,那么可以重复调用next函数,在这些后续调用中,将调用_findnext函数以获取下一个文件。在这种情况下,search_handle对象被传递给函数,并通过类的转换运算符隐式转换为intptr_t。如果_findnext函数返回-1,这意味着搜索中没有更多文件。

main函数的底部,添加以下行以执行搜索:

    string file; 
    while (files.next(file)) 
    { 
        cout << file << endl; 
    }

现在可以编译代码并使用搜索条件运行它。请记住,这受到_findfirst/_findnext函数的限制,因此您可以进行的搜索将非常简单。尝试在命令行中运行此代码,并使用参数搜索Beginning_C++文件夹中的子文件夹:

 search Beginning_C++Ch*

这将给出以Ch开头的子文件夹列表。由于没有理由让search_handle成为一个单独的类,将整个类移到search_handleprivate部分,在handle数据成员的声明之上。编译并运行代码。

总结

通过类,C++提供了一个强大而灵活的机制,用于封装数据和方法,以提供作用于数据的行为。您可以将此代码模板化,以便编写通用代码,并让编译器为您需要的类型生成代码。在本例中,您已经看到了类是面向对象的基础。类封装数据,使得调用者只需要了解预期的行为(在本例中是获取搜索中的下一个结果),而无需了解类如何实现这一点的细节。在下一章中,我们将进一步研究类的其他特性;特别是通过继承实现代码重用。

第七章:面向对象编程简介

到目前为止,您已经看到了如何在函数中将代码模块化,并在类中用代码封装数据。您还看到了如何使用模板编写通用代码。类和封装允许您将代码和数据组合在一起作为一个对象。在本章中,您将学习如何通过继承和组合来重用代码,以及如何使用类继承来编写面向对象的代码。

继承和组合

到目前为止,您所看到的类都是完整的类:您可以在自由存储区或堆栈上创建类的实例。这是因为类的数据成员已经定义,因此可以计算出对象所需的内存量,并且已经提供了类的全部功能。这些被称为具体类

如果您在一个类中有一个证明有用的例程,并且希望在新类中重用,您有几种选择。第一种称为组合。通过组合,您可以将实用程序类的实例作为将使用该例程的类的数据成员添加进去。一个简单的例子是string类--它提供了您从字符串中想要的所有功能。它将根据需要存储的字符数分配内存,并在字符串对象被销毁时释放它使用的内存。您的类使用字符串的功能,但它本身不是一个字符串,因此它将字符串作为数据成员。

第二个选项是使用继承。有许多使用继承的方式,本章将提到其中一些。基本上,继承是指一个类扩展另一个类,被扩展的类称为基类父类超类,而进行扩展的类称为派生类子类子类。然而,有一个重要的概念需要理解:派生类与基类的关系。通常以是一个的方式给出。如果派生类是基类的一种类型,那么这种关系就是继承。mp3 文件是操作系统文件,因此如果您有一个os_file类,那么您可以合理地从中派生出一个mp3_file类。

派生类具有基类的功能和状态(尽管可能无法完全访问它们,稍后将进行解释),因此它可以使用基类的功能。在这种情况下,它类似于组合。然而,存在重大差异。通常情况下,在组合中,组合对象由类使用,而不直接暴露给类的客户端。通过继承,派生类的对象是基类的对象,因此通常客户端代码将看到基类的功能。然而,派生类可以隐藏基类的功能,因此客户端代码将看不到隐藏的基类成员,并且派生类可以覆盖基类的方法并提供自己的版本。

在 C++社区中,关于是否应该使用继承或组合来重用代码存在很多争议,每种方法都有其优缺点。两者都不完美,通常需要妥协。

从类继承

考虑一个包装操作系统的类。这将提供许多方法,以便通过调用操作系统函数来获取文件的创建日期、修改日期和大小。它还可以提供打开文件、关闭文件、将文件映射到内存以及其他有用的方法。以下是一些这样的成员:

    class os_file 
    { 
        const string file_name; 
        int file_handle; 
        // other data members 
    public: 
        long get_size_in_bytes(); 
        // other methods 
    };

mp3 文件是操作系统文件,但有其他操作系统函数可以访问其数据。我们可以决定创建一个mp3_file类,它从os_file派生,以便具有操作系统文件的功能,并通过 mp3 文件的功能进行扩展:

    class mp3_file : public os_file 
    { 
        long length_in_secs; 
        // other data members 
    public: 
        long get_length_in_seconds(); 
        // other methods 
    };

mp3_file类的第一行表示它使用public inheritance(我们稍后会解释什么是 public inheritance,但值得指出的是,这是从一个类派生的最常见方式)。派生类继承了数据成员和方法,派生类的用户可以通过派生类使用基类的成员,取决于访问说明符。在这个例子中,如果某些代码有一个mp3_file对象,它可以从mp3_file类调用get_length_in_seconds方法,也可以从基类调用get_size_in_bytes方法,因为这个方法是public的。

基类方法很可能访问基类数据成员,这说明了一个重要的观点:派生对象包含基类数据成员。在内存中,你可以把派生对象看作是基类对象数据成员加上派生对象中定义的额外数据成员。也就是说,派生对象是基类对象的扩展版本。这在下面的图表中有所说明:

在内存中,os_file对象有两个数据成员,file_namefile_handle,而mp3_file对象有这两个数据成员和一个额外的数据成员length_in_secs

封装原则在 C++中很重要。虽然mp3_file对象包含file_namefile_handle数据成员,但它们应该只能由基类方法来改变。在这段代码中,通过将它们设为private来强制执行这一点。

当创建一个派生对象时,必须首先创建基对象(使用适当的构造函数),同样,当销毁一个派生对象时,首先销毁对象的派生部分(通过派生类的析构函数),然后才调用基类析构函数。考虑以下代码片段,使用前面文本中讨论的成员:

    class os_file 
    { 
    public: 
        os_file(const string& name)  
            : file_name(name), file_handle(open_file(name)) 
        {} 
        ~os_file() { close_file(file_handle); } 
    }; 

    class mp3_file : public os_file 
    { 
    public: 
        mp3_file(const string& name) : os_file(name) {} 
        ~mp3_file() { /* clean up mp3 stuff*/ } 
    };

open_fileclose_file函数将是一些操作系统函数,用于打开和关闭操作系统文件。

派生类不再需要执行关闭文件的操作,因为在派生类析构函数被调用后,基类析构函数~os_file会自动被调用。mp3_file构造函数通过其构造函数成员列表调用基类构造函数。如果你没有显式调用基类构造函数,那么编译器会在派生类构造函数的第一个动作中调用基类的默认构造函数。如果成员列表初始化了数据成员,那么这些数据成员会在任何基类构造函数被调用后初始化。

覆盖方法和隐藏名称

派生类继承了基类的功能(取决于方法的访问级别),因此可以通过派生类的对象调用基类方法。派生类可以实现一个与基类方法具有相同原型的方法,这种情况下,基类方法被派生类方法覆盖,派生类提供功能。派生类通常会覆盖基类方法,以提供特定于派生类的功能;然而,它可以通过使用名称解析运算符调用基类方法:

    struct base 
    { 
        void f(){ /* do something */ } 
        void g(){ /* do something */ } 
    }; 

    struct derived : base 
    { 
        void f() 
        { 
            base::f(); 
            // do more stuff 
        } 
    };

记住,结构体是一个默认成员为publicclass类型,继承默认为public

在这里,base::fbase::g方法将执行一些可供此类实例的用户使用的操作。derived类继承了这两种方法,由于它没有实现g方法,当derived类的实例调用g方法时,它们实际上会调用base::g方法。derived类实现了自己版本的f方法,因此当derived类的实例调用f方法时,它们将调用derived::f而不是基类版本。在这个实现中,我们决定我们需要一些基类版本的功能,所以derived::f明确调用base::f方法:

    derived d; 
    d.f(); // calls derived::f 
    d.g(); // calls base::g

在前面的例子中,该方法首先调用基类版本,然后提供自己的实现。这里没有具体的约定。类库有时是专门为您实现的,以便您从基类派生并使用类库代码。类库的文档将说明您是否应该替换基类实现,或者您是否应该添加到基类实现,如果是这样,您是否会在您的代码之前或之后调用基类方法。

在这个例子中,派生类提供了一个与基类方法完全相同原型的方法来覆盖它。事实上,添加任何与基类中方法同名的方法会隐藏客户端代码中使用派生实例的基类方法。因此,可以将derived类实现如下:

    struct derived : base 
    { 
        void f(int i) 
        { 
            base::f(); 
            // do more stuff with i 
        } 
    };

在这种情况下,base::f方法被隐藏,即使该方法具有不同的原型:

    derived d; 
    d.f(42); // OK 
    d.f();   // won't compile, derived::f(int) hides base::f

相同名称的基类方法被隐藏,因此最后一行将无法编译。但是,您可以通过提供基类名称来显式调用该函数:

    derived d; 
    d.derived::f(42); // same call as above 
    d.base::f();      // call base class method 
    derived *p = &d;  // get an object pointer 
    p->base::f();     // call base class method 
    delete p;

乍一看,这个语法看起来有点奇怪,但一旦你知道.->运算符可以访问成员,并且运算符后面的符号是成员的名称,这种情况下,使用类名和作用域解析运算符明确指定。

到目前为止,所展示的代码通常被称为实现继承,其中一个类从基类继承实现。

使用指针和引用

在 C++中,您可以使用&运算符获取对象(内置类型或自定义类型)在内存中的位置的指针。指针是有类型的,因此使用指针的代码假定指针指向该类型的对象的内存布局。同样,您可以获得对象的引用,引用是对象的别名,也就是说,对引用的操作会在对象上进行。派生类的实例的指针(或引用)可以隐式转换为基类对象的指针(或引用)。这意味着您可以编写一个作用于基类对象的函数,使用基类对象的行为,并且只要参数是指向基类的指针或引用,就可以将任何派生类对象传递给该函数。该函数不知道,也不关心派生类的功能。

您应该将派生对象视为基类对象,并接受它可以被用作基类对象。显然,基类指针只能访问基类的成员:

如果派生类隐藏了基类的成员,这意味着派生类的指针将通过成员名称调用派生版本,但基类指针只能看到基类成员,而看不到派生版本。

如果您有一个基类指针,可以使用static_cast将其转换为派生类指针:

    // bad code 
    void print_y(base *pb) 
    { 
       // be wary of this 
       derived *pd = static_cast<derived*>(pb); 
       cout << "y = " << pd->y << endl; 
    } 

    void f() 
    { 
       derived d; 
       print_y(&d); // implicit cast to base* 
    }

问题在于print_y函数如何保证将基类指针传递给特定派生对象?如果没有开发人员使用该函数的纪律保证他们永远不会传递不同类型的派生类指针,那么它是无法保证的。即使内存中不包含该对象,static_cast操作符也会返回指向derived对象的指针。有一种机制可以对进行强制转换的指针进行类型检查,我们将在本章后面介绍。

访问级别

到目前为止,我们已经看到了类成员的两种访问限定符:publicprivate。在public部分声明的成员可以被类内部和类外部的代码访问,无论是在对象上还是(如果成员是static的话)使用类名。在private部分声明的成员只能被同一类中的其他成员访问。派生类可以访问基类的private成员,但不能访问private成员。还有第三种成员访问方式:protected。在protected部分声明的成员可以被同一类中的方法或任何派生类的方法和友元访问,但不能被外部代码访问:

    class base 
    { 
    protected: 
        void test(); 
    }; 

    class derived : public base 
    { 
    public: 
        void f() { test(); } 
    };

在此代码中,test方法可以被derived类中的成员调用,但不能被类外的代码调用:

    base b; 
    b.test();  // won't compile 
    derived d; 
    d.f();     // OK 
    d.test();  // won't compile

如果您正在编写一个基类,您只打算将其用作基类(客户端代码不应创建其实例),那么将析构函数设置为protected是有意义的:

    class base 
    { 
    public: 
        // methods available through the derived object 
        protected: 
        ~base(){} 
    };

编译器不允许您在自由存储器上创建此类的对象,然后使用delete销毁它,因为此操作符将调用析构函数。同样,编译器也不会允许您在堆栈上创建对象,因为当对象超出范围时,编译器将调用不可访问的析构函数。此析构函数将通过派生类的析构函数调用,因此您可以确保基类的正确清理将发生。这种模式意味着您总是打算使用指向派生类的指针,以便通过调用delete操作符销毁对象。

通过继承改变访问级别

当您在派生类中重写方法时,对该方法的访问由派生类定义。因此,如果基类方法是protectedpublic,则派生类可以更改访问权限:

    class base 
    { 
        protected: 
        void f(); 
    public: 
        void g(); 
    }; 

    class derived : public base 
    { 
    public: 
        void f(); 
        protected: 
        void g(); 
    };

在前面的示例中,base::f方法是protected,因此只有derived类可以访问它。derived类重写了此方法(并且可以调用基类方法,如果使用了完全限定名称),并将其设置为public。类似地,base::g方法是public,但derived类重写了此方法并将其设置为protected(如果需要,它也可以将该方法设置为private)。

您还可以使用using语句将派生类中的protected基类公开为public成员:

    class base 
    { 
    protected: 
        void f(){ /* code */}; 
    }; 

    class derived: public base 
    { 
    public: 
        using base::f; 
    };

现在,derived::f方法是public,而不是派生类创建一个新方法。更好地使用此功能的方法是将方法设置为private,以便派生类(或者如果它是public,则通过实例)无法访问它,或者将其设置为protected,以便外部代码无法访问该成员:

    class base 
    { 
    public: 
        void f(); 
    }; 

    class derived: public base 
    { 
    protected: 
        using base::f; 
    };

前面的代码可以这样使用:

    base b; 
    b.f(); // OK 
    derived d; 
    d.f(); // won't compile

最后一行不会编译,因为f方法是protected。如果意图是仅在派生类中使该方法可用,并且不在可能从中派生的任何类中使其可用,您可以在派生类的private部分使用using语句;这类似于删除基类方法:

    class derived: public base 
    { 
    public: 
        void f() = delete; 

        void g() 
        { 
            base::f(); // call the base class method 
        } 
    };

f方法无法通过derived类使用,但该类可以调用base类方法。

继承访问级别

之前,您看到了要从一个类派生,您需要提供基类名称并给出继承访问限定符;到目前为止的示例都使用了public继承,但您也可以使用protectedprivate继承。

这是类和结构之间的另一个区别。对于类,如果您省略了继承访问说明符,编译器将假定它是私有的;对于结构,如果您省略了继承访问说明符,编译器将假定它是公共的。

继承说明符应用更多的访问限制,而不是放宽它们。访问说明符不确定它对基类成员的访问权限,而是通过派生类(即通过类的实例,或者如果另一个类从它派生)改变这些成员的可访问性。如果一个基类有private成员,并且一个类使用public继承进行继承,那么派生类仍然无法访问private成员;它只能访问publicprotected成员,派生类的对象只能访问public成员,而从这个类派生的类只能访问publicprotected成员。

如果派生类通过protected 继承派生,它仍然具有与publicprotected成员相同的对基类的访问权限,但是基类的publicprotected成员现在将通过派生类视为protected,因此它们可以被进一步派生的类访问,但不能通过实例访问。如果一个类通过私有继承派生,那么所有基类成员在派生类中都变为private;因此,尽管派生类可以访问publicprotected成员,但从它派生的类不能访问任何基类成员。

保护继承的一种看法是,如果派生类在类的protected部分对基类的每个public成员都有一个using语句。类似地,私有继承就好像您已删除了基类的每个publicprotected方法。

一般来说,大多数继承都将通过public 继承。但是,当您想要从基类访问一些功能但不希望其功能对从您的类派生的类可用时,private 继承是有用的。这有点像组合,您在使用功能但不希望该功能直接暴露。

多重继承

C++允许您从多个基类继承。当与接口一起使用时,这是一个强大的功能,我们将在本章后面发现。它对于实现继承可能很有用,但可能会引起一些问题。语法很简单:您提供一个要继承的类的列表:

    class base1 { public: void a(); }; 
    class base2 { public: void b(); }; 
    class derived : public base1, public base2  
    { 
    public: 
        // gets a and b 
    };

使用多重继承的一种方法是构建提供某些功能或服务的类库。要在您的类中获得这些服务,您可以将库中的类添加到基类列表中。通过实现继承来创建类的构建块方法存在问题,我们稍后会看到,通常更好的方法是使用组合。

在考虑多重继承时,重要的是仔细审查您是需要通过继承获取服务还是组合更合适。如果一个类提供了一个您不希望实例使用的成员,并且您决定需要删除它,这是一个很好的迹象,表明您应该考虑组合。

如果两个类具有相同名称的成员,则可能会出现问题。最明显的情况是如果基类具有相同名称的数据成员:

    class base1 { public: int x = 1; }; 
    class base2 { public: int x = 2; }; 
    class derived : public base1, public base2 {};

在前面的例子中,两个基类都有一个名为x的数据成员。derived类继承自这两个类,这意味着它只会得到一个名为x的数据成员吗?不是的。如果是这样的话,那么base1类将能够修改base2类中的数据成员,而不知道这会影响到另一个类,同样地,base2类将发现它的数据成员被base1类修改,即使那个类不是friend。因此,当你从两个具有相同名称的数据成员的类派生时,派生类会得到这两个数据成员。

这再次说明了保持封装的重要性。这样的数据成员应该是private的,并且只能由基类进行更改。

派生类(以及使用实例的代码,如果数据成员是可访问的)可以通过它们的全名来区分它们:

    derived d; 
    cout << d.base1::x << endl; // the base1 version 
    cout << d.base2::x << endl; // the base2 version

这个类可以用下面的图表来总结,说明了三个类base1base2derived所占用的内存:

如果你保持封装并将数据成员设为private,并且只通过访问器方法访问,那么派生类将不能直接访问数据成员,也不会看到这个问题。然而,方法也会出现相同的问题,但即使方法有不同的原型,问题也会出现:

    class base1 { public: void a(int); }; 
    class base2 { public: void a(); }; 
    class derived : public base1, public base2 {};

在这种情况下,两个基类都有一个名为a的方法,但原型不同。当使用derived类时,这会导致问题,即使通过参数可能很明显应该调用哪个方法。

    derived d; 
    d.a();          // should be a from base2, compiler still complains

这段代码将无法编译,编译器会抱怨方法调用是模棱两可的。再次,这个问题的解决方法很简单,你只需要指定使用哪个基类方法:

    derived d; 
    d.base1::a(42); // the base1 version 
    d.base2::a();   // the base2 version

多重继承可能会变得更加复杂。问题出现在如果你有两个类都从同一个基类派生,然后你创建另一个类从这两个类派生。新类会得到最顶层基类成员的两个副本吗?一个通过每个直接基类?

在继承的第一级,每个类(base1base2)都从最终基类继承了数据成员(这里,数据成员都被称为base::x,以说明它们是从最终基类base继承的)。最派生类derived继承了两个数据成员,那么base::x是哪个?答案是只有一个,base1::xbase::x,因为它是继承列表中的第一个。当base方法改变它时,改变将在base1中通过base1::x看到。base2::x成员是一个独立的数据成员,当base改变base::x时不受影响。这可能是一个意想不到的结果:最派生类从它的父类中都继承了x

这可能不是你想要的行为。这个问题通常被称为菱形继承问题,并且从前面的图表中应该很明显,这个名字是从哪里来的。解决方法很简单,稍后在本章中会介绍。

对象切片

在本章的前面,你看到如果你使用一个基类指针指向一个派生对象,只有基类成员可以被安全访问。其他成员仍然存在,但只能通过适当的派生类指针访问。

然而,如果你将一个派生类对象转换为一个基类对象,会发生另外的事情:你创建了一个新对象,那个对象就是基类对象,只是基类对象。你转换的变量只有基类对象的内存,所以结果只有派生对象的基类对象部分:

    struct base { /*members*/ }; 
    struct derived : base { /*members*/ }; 

    derived d; 
    base b1 = d; // slicing through the copy constructor   
    base b2; 
    b2 = d;      // slicing through assignment

在这里,对象b1b2是通过对derived类对象d进行切片来创建的。这段代码看起来有点反常,你不太可能写出来,但如果你通过值传递一个对象给一个函数,情况很可能会发生:

    void f(base b) 
    { 
        // can only access the base members 
    }

如果你将一个derived对象传递给这个函数,将调用base的复制构造函数来创建一个新对象,切掉derived类的数据成员。在大多数情况下,你不希望出现这种行为。如果你的基类有虚方法,并且期望虚方法提供的多态功能(虚方法稍后在本章中介绍),这个问题也会有意想不到的行为。最好总是通过引用传递对象。

引入多态

多态来自希腊语,意为多种形态。到目前为止,你已经有了多态的基本形式。如果你使用一个指向对象的基类指针,那么你可以访问基类的行为,如果你有一个派生类指针,你就会得到派生类的行为。这并不像看起来那么简单,因为派生类可以实现自己版本的基类方法,所以你可以有不同的行为实现。

你可以从一个基类派生出多个类:

    class base { /*members*/ }; 
    class derived1 : public base { /*members*/ }; 
    class derived2 : public base { /*members*/ }; 
    class derived3 : public base { /*members*/ };

由于 C++是强类型的,这意味着一个派生类的指针不能用来指向另一个派生类。所以你不能使用derived1*指针来访问derived2的实例,它只能指向derived1类型的对象。即使这些类有相同的成员,它们仍然是不同的类型,它们的指针也是不同的。然而,所有的派生类都有一个共同点,那就是基类。派生类指针可以被隐式转换为基类指针,所以base*指针可以指向basederived1derived2derived3的实例。这意味着一个接受base*指针作为参数的通用函数可以传递给这些类的任何一个指针。这是接口的基础,我们稍后会看到。

多态的方面是,通过指针(或引用),一个类的实例可以被视为其继承层次结构中任何一个类的实例。

虚方法

一个基类指针或引用只能访问基类的功能,这是有意义的,但它是有限制的。如果你有一个car类,它提供了汽车的接口,油门和刹车来改变速度,方向盘和倒挡来改变方向-你可以从这个类派生出各种其他类型的汽车:跑车、SUV 或家庭轿车。当你踩油门时,你期望汽车有 SUV 的扭矩,如果你的车是 SUV,或者如果它是跑车,你期望它有跑车的速度。同样,如果你在car指针上调用accelerate方法,而该指针指向suv,那么你期望得到反映 SUV 扭矩的方法,如果car指针指向sportscar对象,你期望得到性能加速。之前我们说过,如果你通过基类指针访问派生类实例,那么你将得到基类方法的实现。这意味着,当你在指向suvsportscar对象的car指针上调用accelerate方法时,你仍然会得到car::accelerate的实现,而不是suv::acceleratesportscar::accelerate,这是你想要的。

这种通过基类指针调用派生方法的行为被称为方法分派。通过基类指针调用方法的代码并不知道指针指向的对象的类型,但它仍然获得了该对象的功能,因为调用了该对象上的方法。这种方法分派不是默认应用的,因为它在内存和性能上都需要一些额外的成本。

可以参与方法分派的方法在基类中用关键字virtual标记,因此通常被称为虚方法。当你通过基类指针调用这样的方法时,编译器会确保调用实际对象类的方法。由于每个方法都有一个this指针作为隐藏参数,方法分派机制必须确保在调用方法时this指针是适当的。考虑以下例子:

    struct base  
    {  
        void who() { cout << "base "; }  
    }; 
    struct derived1 : base  
    {  
        void who() { cout << "derived1 "; }  
    }; 
    struct derived2 : base 
    { 
        void who() { cout << "derived2 "; } 
    }; 
    struct derived3 : derived2 
    { 
        void who() { cout << "derived3 "; } 
    }; 

    void who_is_it(base& r) 
    { 
        p.who(); 
    } 

    int main() 
    { 
        derived1 d1; 
        who_is_it(d1); 
        derived2 d2; 
        who_is_it(d2); 
        derived3 d3; 
        who_is_it(d3); 
        cout << endl; 
        return 0; 
    }

有一个基类和两个子类,derived1derived2。通过derived2进一步继承到一个名为derived3的类。基类实现了一个名为who的方法,打印类名。这个方法在每个派生类上都被适当地实现,所以当在derived3对象上调用这个方法时,控制台将打印derived3main函数创建了每个派生类的一个实例,并将每个实例通过引用传递给一个名为who_is_it的函数,该函数调用who方法。这个函数有一个参数,是对base的引用,因为这是所有类的基类(对于derived3,它的直接基类是derived2)。当你运行这段代码时,结果将如下所示:

    base base base

这个输出来自对who_is_it函数的三次调用,传递的对象是derived1derived2derived3类的实例。由于参数是对base的引用,这意味着调用base::who方法。

做一个简单的改变将完全改变这种行为:

    struct base 
    { 
 virtual void who() { cout << "base "; } 
    };

所有改变的是在基类的who方法中添加了virtual关键字,但结果是显著的。当你运行这段代码时,结果将如下所示:

     derived1 derived2 derived3

你没有改变who_is_it函数,也没有改变派生类的方法,但是who_is_it的输出与之前相比非常不同。who_is_it函数通过引用调用who方法,但是现在,与其调用base::who方法不同,实际对象的who方法被调用。who_is_it函数没有做任何额外的工作来确保派生类方法被调用--它和之前完全一样。

derived3类不是直接从base派生的,而是从derived2派生的,后者本身是base的子类。即便如此,方法分派也适用于derived3类的实例。这说明了无论virtual应用到继承链的多高,方法分派仍然适用于派生类的继承方法。

重要的是要指出,方法分派应用于在基类中应用了virtual的方法。基类中没有标记为virtual的任何其他方法都将在没有方法分派的情况下被调用。派生类将继承一个virtual方法并自动获得方法分派,它不必在任何覆盖的方法上使用virtual关键字,但这是一个有用的视觉指示,说明方法如何被调用。

通过派生类实现virtual方法,你可以使用一个容器来保存所有这些类的实例的指针,并调用它们的virtual方法,而不需要调用代码知道对象的类型:

    derived1 d1; 
    derived2 d2; 
    derived3 d3; 

    base *arr[] = { &d1, &d2, &d3 }; 
    for (auto p : arr) p->who(); 
    cout << endl;

这里,arr内置数组保存了三种类型的对象的指针,范围for循环遍历数组并调用方法。这给出了预期的结果:

     derived1 derived2 derived3

关于前面的代码有三个重要的点:

  • 这里使用内置数组是很重要的;像vector这样的标准库容器存在问题。

  • 重要的是数组保存的是指针,而不是对象。如果你有一个base对象数组,它们将通过切片初始化派生对象。

  • 还重要的是使用堆栈对象的地址。这是因为析构函数存在问题。

这三个问题将在后面的章节中讨论。

要使用方法分派调用virtual方法,派生类方法必须与基类的virtual方法在名称、参数和返回类型方面具有相同的签名。如果其中任何一个不同(例如,参数不同),那么编译器将认为派生方法是一个新函数,因此当您通过基指针调用virtual方法时,将得到基方法。这是一个相当隐匿的错误,因为代码将编译,但您将得到错误的行为。

最后一段的一个例外是,如果两个方法的返回类型是协变的,即一个类型可以转换为另一个类型。

虚方法表

通过虚方法进行方法分派的行为是您需要了解的全部,但了解 C++编译器如何实现方法分派是有帮助的,因为它突出了virtual方法的开销。

当编译器在类上看到一个virtual方法时,它将创建一个方法指针表,称为vtable,并将类中每个virtual方法的指针放入表中。该类将有一个vtable的单个副本。编译器还将在类的每个实例中添加一个指向该表的指针,称为vptr。因此,当您将方法标记为virtual时,将在运行时为该类创建一个vtable的单个内存开销,并为从该类创建的每个对象添加一个额外的数据成员,即vptr的内存开销。通常,当客户端代码调用(非内联)方法时,编译器将在客户端代码中将跳转到该方法的函数。当客户端代码调用virtual方法时,编译器必须解引用vptr以获取vtable,然后使用存储在其中的适当地址。显然,这涉及额外的间接级别。

在基类中的每个virtual方法都有一个单独的vtable条目,按照它们声明的顺序排列。当您从具有virtual方法的基类派生时,派生类也将有一个vptr,但编译器将使其指向派生类的vtable,也就是说,编译器将使用派生类中virtual方法实现的地址填充vtable。如果派生类没有实现继承的virtual方法,则vtable中的指针将指向基类方法。这在下图中有所说明:

在左侧,有两个类;基类有两个虚函数,派生类只实现其中一个。在右侧,有一个内存布局的示例。显示了两个对象,一个是base对象,一个是derived对象。每个对象都有一个单独的vptr,后面是类的数据成员,数据成员的排列方式是基类数据成员首先排列,然后是派生类数据成员。vtable指针包含指向virtual方法的方法指针。对于基类,方法指针指向base类上实现的方法。对于派生类,只有第二个方法在derived类中实现,因此该类的vtable中有一个指向base类中的虚方法和另一个指向derived类中的虚方法。

这引发了一个问题:如果派生类引入了一个新方法,在基类中不可用,并将其设为virtual,会发生什么?这并非不可想象,因为最终的基类可能只提供所需行为的一部分,从它派生的类提供更多的行为,通过子类上的虚方法分派来调用。实现非常简单:编译器为类上的所有virtual方法创建一个vtable,因此,如果派生类有额外的virtual方法,这些指针将出现在vtable中,位于从基类继承的virtual方法指针之后。当通过基类指针调用对象时,无论该类在继承层次结构中的位置如何,它只会看到与其相关的vtable条目:

多重继承和虚方法表

如果一个类从多个类派生,并且父类有virtual方法,那么派生类的vtable将是其父类的vtable的组合,按照派生列表中列出的父类的顺序排列:

如果通过基类指针访问对象,则vptr将访问与该基类相关的vtable部分。

虚方法、构造和析构

对象的派生类部分直到构造函数完成后才会被构造,因此,如果调用一个virtual方法,vtable条目将无法设置为调用正确的方法。同样,在析构函数中,对象的派生类部分已经被销毁,包括它们的数据成员,因此无法调用派生类上的virtual方法,因为它们可能会尝试访问不再存在的数据成员。如果在这些情况下允许virtual方法分派,结果将是不可预测的。你不应该在构造函数或析构函数中调用virtual方法,如果这样做,调用将解析为基类版本的方法。

如果一个类预期通过基类指针调用virtual方法分派,那么你应该使析构函数virtual。我们这样做是因为用户可能会删除一个基类指针,在这种情况下,你会希望调用派生析构函数。如果析构函数不是virtual,并且删除了基类指针,那么只会调用基类析构函数,可能导致内存泄漏。

一般来说,基类的析构函数应该是protected且非虚拟的,或者是publicvirtual的。如果意图是通过基类指针使用类,那么析构函数应该是publicvirtual,以便调用派生类的析构函数,但如果基类旨在提供仅通过派生类对象可用的服务,那么你不应该直接访问基类对象,因此析构函数应该是protected且非虚拟的。

容器和虚方法

virtual方法的一个优势是将由基类相关的对象放入容器;之前,我们看到了使用内置基类指针数组的特定情况,但标准库容器呢?举个例子,假设你有一个类层次结构,其中有一个基类base,三个派生类derived1derived2derived3,每个类都实现了一个virtual方法who,就像之前使用的那样。尝试将对象放入容器可能如下所示:

    derived1 d1; 
    derived2 d2; 
    derived3 d3; 
    vector<base> vec = { d1, d2, d3 }; 
    for (auto b : vec) b.who(); 
    cout << endl;

问题在于向量保存了base对象,因此在初始化列表中的项目放入容器时,它们实际上被用来初始化新的base对象。由于vec的类型是vector<base>push_back方法将切片对象。因此,调用每个对象上的who方法的语句将打印一个字符串base

为了进行virtual方法分派,我们需要将整个对象放入容器中。我们可以使用指针或引用来实现这一点。使用指针,你可以使用堆栈对象的地址,只要vector的生存期不长于容器中的对象。如果你使用在堆上创建的对象,那么你需要确保对象被适当地删除,你可以使用智能指针来实现这一点。

你可能会想创建一个引用容器:

    vector<base&> vec;

这将导致一系列错误;不幸的是,它们都没有完全指示问题。vector必须包含可复制构造和可赋值的类型。这对引用来说并不成立,因为它们是实际对象的别名。有一个解决方案。<functional>头文件包含一个名为reference_wrapper的适配器类,它有一个复制构造函数和赋值运算符。该类将对象的引用转换为指向该对象的指针。现在你可以写如下代码:

    vector<reference_wrapper<base> > vec = { d1, d2, d3 }; 
    for (auto b : vec) b.get().who(); 
    cout << endl;

使用reference_wrapper的缺点是,要调用包装对象(及其虚拟方法),你需要调用get方法,它将返回对包装对象的引用

友元和继承

在 C++中,友元关系不会被继承。如果一个类使另一个类(或函数)成为友元,这意味着友元可以访问它的privateprotected成员,就好像友元是类的成员一样。如果你从friend类派生,新类不是第一个类的友元,并且它无法访问第一个类的成员。

在上一章中,我们看到了如何通过编写全局插入运算符并将其作为类的friend来将对象插入ostream对象中进行打印。在下面的例子中,friend函数是内联实现的,但实际上它是一个独立的全局函数,可以在没有对象或使用类名解析的情况下调用。

    class base 
    {
        int x = 0; 
    public: 
        friend ostream& operator<<(ostream& stm, const base& b) 
        { 
            // thru b we can access the base private/protected members 
            stm << "base: " << b.x << " "; 
            return stm; 
        } 
    };

如果我们从base类派生,我们将需要实现一个friend函数,将派生对象插入流中。由于这个函数是一个friend,它将能够访问派生类的privateprotected成员,但它不能访问基类的private成员。这种情况意味着作为派生类friend的插入运算符只能打印对象的一部分。

如果一个derived类对象被转换为base类,比如通过引用或指针传递,然后打印对象,将调用base版本的插入运算符。插入运算符是一个friend函数,因此它可以访问类的非公共数据成员,但作为friend并不足以使它成为一个virtual方法,因此没有virtual方法分派。

虽然friend函数不能被调用为virtual方法,但它可以调用virtual方法并进行方法分派:

    class base 
    { 
        int x = 0;  
        protected: 
        virtual void output(ostream& stm) const { stm << x << " "; } 
    public: 
        friend ostream& operator<<(ostream& stm, const base& b) 
        { 
            b.output(stm); 
            return stm; 
        } 
    }; 

    class derived : public base 
    { 
        int y = 0; 
    protected: 
        virtual void output(ostream& stm) const 
        { 
            base::output(stm); 
            stm << y << " "; 
        } 
    };

在这个版本中,只有一个插入运算符,它是为base类定义的。这意味着任何可以转换为base类的对象都可以使用这个运算符进行打印。打印对象的实际工作被委托给了一个叫做outputvirtual函数。这个函数是受保护的,因为它只打算被类或派生类使用。base类版本打印出了基类的数据成员。derived类版本有两个任务:打印出基类的数据成员,然后打印出特定于derived类的数据成员。第一个任务是通过用基类名称限定名称来调用方法的基类版本来完成的。第二个任务很简单,因为它可以访问自己的数据成员。如果你从derived派生另一个类,那么它的output函数版本将是类似的,但它将调用derived::output

现在,当一个对象被插入到ostream对象中,比如cout,插入运算符将被调用,并且对output方法的调用将被分派到适当的派生类。

覆盖和 final

如前所述,如果你错误地输入了派生virtual方法的原型,例如,使用了错误的参数类型,编译器将把该方法视为新方法并进行编译。派生类不覆盖基类的方法是完全合法的;这是一个你经常会想要使用的特性。然而,如果你在输入派生virtual方法的原型时出现错误,当你打算调用你的新版本时,基本方法将被调用。override修饰符旨在防止这种错误。当编译器看到这个修饰符时,它知道你打算覆盖从基类继承的virtual方法,并且它将搜索继承链以找到合适的方法。如果找不到这样的方法,那么编译器将发出错误:

    struct base  
    {  
        virtual int f(int i);  
    }; 

    struct derived: base  
    {  
        virtual int f(short i) override; 
    };

在这里,derived::f不会编译,因为在继承链中没有具有相同签名的方法。override修饰符让编译器执行一些有用的检查,因此在所有派生的重写方法上使用它是一个好习惯。

C++11 还提供了一个称为final的修饰符,你可以将其应用于方法以指示派生类不能覆盖它,或者将其应用于类以指示你不能从它派生:

    class complete final { /* code */ }; 
    class extend: public complete{}; // won't compile

很少会想要使用这个。

虚拟继承

之前,我们谈到了多重继承中所谓的菱形问题,其中一个类通过两个基类从单个祖先类继承。当一个类从另一个类继承时,它将获得父类的数据成员,以便派生类的实例被视为由基类数据成员和派生类数据成员组成。如果父类都是从同一个祖先类派生的,它们将分别获得祖先类的数据成员,导致最终派生类从每个父类获得祖先类的数据成员的副本:

    struct base { int x = 0; }; 
    struct derived1 : base { /*members*/ }; 
    struct derived2 :  base { /*members*/ }; 
    struct most_derived : derived1, derived2 { /*members*/ };

创建most_derived类的实例时,对象中会有两个base的副本:一个来自derived1,一个来自derived2。这意味着most_derived对象将有两个数据成员x的副本。显然,派生类的意图是只获取祖先类的数据成员的一个副本,那么如何实现呢?这个问题的解决方案是虚拟继承

    struct derived1 : virtual base { /*members*/ }; 
    struct derived2 : virtual base { /*members*/ };

没有虚拟继承时,派生类只调用其直接父类的构造函数。当你使用virtual继承时,most_derived类有责任调用最顶层父类的构造函数,如果你没有显式调用基类构造函数,编译器将自动调用默认构造函数:

    derived1::derived1() : base(){} 
    derived2::derived2() : base(){} 
    most_derived::most_derived() : derived1(), derived2(), base(){}

在前面的代码中,most_derived构造函数调用base构造函数,因为这是其父类通过虚拟继承继承的基类。虚拟基类总是在非虚拟基类之前创建。尽管在most_derived构造函数中调用了base构造函数,我们仍然必须在派生类中调用base构造函数。如果我们进一步从most_derived派生,那么该类也必须调用base的构造函数,因为那是base对象将被创建的地方。虚拟继承比单一或多重继承更昂贵。

抽象类

具有virtual方法的类仍然是一个具体类--你可以创建类的实例。你可能决定只提供部分功能,希望用户必须从类中派生并添加缺失的功能。

一种方法是提供一个没有代码的virtual方法。这意味着您可以在类中调用virtual方法,并且在运行时,将调用派生类中的方法版本。但是,尽管这为您在代码中调用派生方法提供了一种机制,但它并不强制实现这些virtual方法。相反,派生类将继承空的virtual方法,如果它不覆盖它们,客户端代码将能够调用空方法。您需要一种机制来强制派生类提供这些virtual方法的实现。

C++提供了一种称为纯虚方法的机制,表示该方法应该被派生类重写。语法很简单,您可以使用= 0标记该方法:

    struct abstract_base 
    { 
 virtual void f() = 0; 
        void g() 
        { 
            cout << "do something" << endl; 
            f(); 
        } 
    };

这是完整的类;这是该类为方法f的定义提供的全部内容。即使方法g调用了没有实现的方法,这个类也会编译。但是,以下内容将无法编译:

    abstract_base b;

通过声明纯虚函数,使类成为抽象类,这意味着您无法创建实例。但是,您可以创建指向该类的指针或引用,并对其调用代码。这个函数将编译:

    void call_it(abstract_base& r) 
    { 
        r.g(); 
    }

此函数只知道类的公共接口,不关心其实现方式。我们已经实现了方法g来调用方法f,以表明您可以在同一类中调用纯虚方法。实际上,您也可以在类外调用纯虚函数;这段代码同样有效:

    void call_it2(abstract_base& r) 
    { 
        r.f(); 
    }

使用抽象类的唯一方法是从中派生并实现纯虚函数:

    struct derived1 : abstract_base 
    { 
        virtual void f() override { cout << "derived1::f" << endl; } 
    }; 

    struct derived2 : abstract_base 
    { 
        virtual void f() override { cout << "derived2::f" << endl; } 
    };

以下是从抽象类派生的两个类,它们都实现了纯虚函数。这些是具体类,您可以创建它们的实例:

    derived1 d1; 
    call_it(d1); 
    derived2 d2; 
    call_it(d2);

抽象类用于指示特定功能必须由派生类提供,并且= 0语法表示抽象类未提供方法体。实际上,情况比这更微妙;类必须是派生的,必须在派生类上定义调用的方法,但抽象基类也可以为该方法提供方法体:

    struct abstract_base 
    { 
        virtual int h() = 0 { return 42; } 
    };

同样,这个类不能被实例化,您必须从中派生,并且必须实现该方法才能实例化对象:

    struct derived : abstract_base 
    { 
        virtual int h() override { return abstract_base::h() * 10; } 
    };

派生类可以调用抽象类中定义的纯虚函数,但是当外部代码调用这样的方法时,它将始终导致(通过方法分派)调用派生类上虚方法的实现。

获取类型信息

C提供了类型信息,也就是说,您可以获取该类型特有的信息,并对其进行标识。C是一种强类型语言,因此编译器将在编译时确定类型信息,并在变量类型之间进行转换时强制执行类型规则。编译器进行的任何类型检查,您作为开发人员也可以进行。一般的经验法则是,如果需要使用static_castconst_castreinterpret_cast或类 C 风格的转换,那么您正在让类型执行其不应执行的操作,因此应重新考虑重写代码。编译器非常擅长告诉您类型不匹配的地方,因此您应该将其视为重新评估代码的提示。

不进行转换的规则可能过于严格,通常使用转换的代码更容易编写和阅读,但这样的规则确实让您始终质疑是否需要进行转换。

当您使用多态时,通常会得到一个指向与对象类型不同的类型的指针或引用,当您转向接口编程时,情况变得尤为真实,因为实际对象并不重要,重要的是行为。可能会有需要在编译时无法帮助您的情况。C++提供了一种获取类型信息的机制,称为运行时类型信息RTTI),因为您可以在运行时获取此信息。使用对象上的typeid运算符获取此信息:

    string str = "hello"; 
    const type_info& ti = typeid(str); 
    cout << ti.name() << endl;

在命令行打印以下结果:

    class std::basic_string<char,struct std::char_traits<char>,
 class std::allocator<char> >

这反映了string类实际上是模板类basic_stringtypedef,字符类型为char,字符特性由char_traits类的特化描述,以及分配器对象(用于维护字符串使用的缓冲区),这是allocator类的特化。

typeid运算符返回一个type_info对象的const引用,在这种情况下,我们使用name方法返回对象类型的const char指针的名称。这是类型名称的可读版本。类型名称实际上存储在一个紧凑的装饰名称中,可以通过raw_name方法获得,但如果您想根据它们的类型(例如在字典对象中)存储对象,那么比较有效的机制是使用hash_code方法返回的 32 位整数,而不是装饰名称。在所有情况下,对于相同类型的所有对象,返回的值将是相同的,但对于另一种类型的对象则不同。

type_info类没有复制构造函数或复制赋值运算符,因此无法将此类的对象放入容器中。如果要将type_info对象放入像map这样的关联容器中,则有两种选择。首先,可以将type_info对象的指针放入容器中(可以从引用中获取指针);在这种情况下,如果容器是有序的,则需要定义比较运算符。type_info类有一个before方法,可用于比较两个type_info对象。

第二个选项(在 C++11 中)是使用type_index类的对象作为关联容器的键,该类用于包装type_info对象。

type_info类旨在是只读的,创建实例的唯一方法是通过typeid运算符。但是,您可以在type_info对象上调用比较运算符==!=,这意味着您可以在运行时比较对象的类型。

由于typeid运算符可以应用于变量和类型,这意味着您可以使用该运算符执行安全的转换,避免切片或转换为完全不相关的类型:

    struct base {}; 
    struct derived { void f(); }; 

    void call_me(base *bp) 
    { 
        derived *dp = (typeid(*bp) == typeid(derived))  
            ? static_cast<derived*>(bp) : nullptr; 
        if (dp != nullptr) dp->f(); 
    } 

    int main() 
    { 
        derived d; 
        call_me(&d); 
        return 0; 
    }

此函数可以为从base类派生的任何类的指针。第一行使用条件运算符,其中比较是函数参数指向的对象的类型信息与类derived的类型之间的比较。如果指针指向derived对象,则转换将起作用。如果指针指向另一个派生类型的对象,但不是derived类,则比较将失败,并且表达式将求值为nullptr。只有当指针指向derived类的实例时,call_me函数才会调用f方法。

C++提供了一个执行运行时的转换操作符,这种类型检查在运行时称为dynamic_cast。如果对象可以转换为请求的类型,则操作将成功并返回有效指针。如果对象无法通过请求的指针访问,则转换失败,操作符返回nullptr。这意味着每当您使用dynamic_cast时,都应该在使用之前检查返回的指针。call_me函数可以重写如下:

    void call_me(base *bp) 
    { 
        derived *dp = dynamic_cast<derived*>(bp); 
        if (dp != nullptr) dp->f(); 
    }

这本质上是与之前相同的代码;dynamic_cast运算符执行运行时类型检查并返回适当的指针。

请注意,您不能进行向下转换,无论是到virtual基类指针还是到通过protectedprivate继承派生的类。dynamic_cast运算符可用于除向下转换之外的转换;显然,它将适用于向上转换(到基类,尽管不是必要的),它可用于侧向转换:

    struct base1 { void f(); }; 
    struct base2 { void g(); }; 
    struct derived : base1, base2 {};

这里有两个基类,因此如果您通过其中一个基类指针访问派生对象,您可以使用dynamic_cast运算符将其转换为另一个基类的指针:

    void call_me(base1 *b1)  
    { 
        base2 *b2 = dynamic_cast<base2*>(b1); 
        if (b2 != nullptr) b2->g(); 
    }

智能指针和虚方法

如果您想使用动态创建的对象,您将希望使用智能指针来管理它们的生命周期。好消息是,virtual方法分派通过智能指针(它们只是对象指针的包装器)工作,坏消息是,当您使用智能指针时,类关系会丢失。让我们来看看为什么。

例如,以下两个类是通过继承相关的:

    struct base  
    {  
        Virtual ~base() {} 
        virtual void who() = 0;  
    }; 

    struct derived : base  
    {  
        virtual void who() { cout << "derivedn"; }  
    };

这很简单:实现一个virtual方法,指示对象的类型。有一个virtual析构函数,因为我们将把生命周期管理交给智能指针对象,并且我们希望确保适当地调用derived类析构函数。您可以使用make_sharedshared_ptr类的构造函数在堆上创建对象:

    // both of these are acceptable 
    shared_ptr<base> b_ptr1(new derived);  
    shared_ptr<base> b_ptr2 = make_shared<derived>();

派生类指针可以转换为基类指针,这在第一条语句中是明确的:new返回一个derived*指针,传递给期望一个base*指针的shared_ptr<base>构造函数。第二条语句中的情况稍微复杂一些。make_shared函数返回一个临时的shared_ptr<derived>对象,它被转换为一个shared_ptr<base>对象。这是通过shared_ptr类上的一个转换构造函数执行的,该构造函数调用了一个名为__is_convertible_to编译器内在,它确定一个指针类型是否可以转换为另一个。在这种情况下,有一个向上转换,因此允许转换。

编译器内在本质上是编译器提供的函数。在这个例子中,__is_convertible_to(derived*, base*)将返回true,而__is_convertible_to(base*, derived*)将返回false。除非您正在编写库,否则您很少需要了解内在本质。

由于在使用make_shared函数的语句中创建了一个临时对象,因此使用第一条语句更有效。

shared_ptr对象上的operator->将直接访问包装的指针,因此这意味着以下代码将执行virtual方法分派,如预期的那样:

    shared_ptr<base> b_ptr(new derived); 
    b_ptr->who(); // prints "derived"

b_ptr超出范围时,智能指针将确保通过基类指针销毁派生对象,并且由于我们有一个virtual析构函数,适当的销毁将发生。

如果您有多重继承,您可以使用dynamic_cast(和 RTTI)在基类指针之间进行转换,以便只选择您需要的行为。考虑以下代码:

    struct base1  
    {  
        Virtual ~base1() {} 
        virtual void who() = 0;  
    }; 

    struct base2  
    {  
        Virtual ~base2() {} 
        virtual void what() = 0;  
    }; 

    struct derived : base1, base2  
    {  
        virtual void who()  { cout << "derivedn"; }  
        virtual void what() { cout << "derivedn"; }  
    };

如果您有指向这些基类的指针,您可以将一个转换为另一个:

    shared_ptr<derived> d_ptr(new derived); 
    d_ptr->who(); 
    d_ptr->what(); 

    base1 *b1_ptr = d_ptr.get(); 
    b1_ptr->who(); 
    base2 *b2_ptr = dynamic_cast<base2*>(b1_ptr); 
    b2_ptr->what();

whowhat方法可以在derived*指针上调用,因此它们也可以在智能指针上调用。以下行获取基类指针,以便访问特定行为。在这段代码中,我们调用get方法从智能指针获取原始指针。这种方法的问题在于现在有一个指向对象的指针,它没有受到智能指针生命周期管理的保护,因此代码可能调用delete来删除b1_ptrb2_ptr指针,从而在智能指针尝试删除对象时造成问题。

这段代码可以运行,而且在这段代码中动态创建的对象有正确的生命周期管理,但是像这样访问原始指针本质上是不安全的,因为无法保证原始指针不会被删除。诱惑是使用智能指针:

    shared_ptr<base1> b1_ptr(d_ptr.get());

问题在于,尽管类base1derived是相关的,但类shared_ptr<derived>shared_ptr<base1>相关,因此每种智能指针类型将使用不同的控制块,即使它们指向同一个对象shared_ptr类将使用控制块引用计数,并在引用计数降至零时删除对象。拥有两个不相关的shared_ptr对象和两个控制块指向同一个对象意味着它们将独立地尝试管理derived对象的生命周期,这最终意味着一个智能指针在另一个智能指针完成之前删除对象。

这里有三条信息:智能指针是指针的轻量级包装器,所以你可以使用方法分派调用virtual方法;然而,要谨慎使用从智能指针获取的原始指针,并且要记住,虽然你可以有许多指向同一对象的shared_ptr对象,但它们必须是相同类型的,以便只使用一个控制块。

接口

纯虚函数和虚方法分派导致了一种非常强大的编写面向对象代码的方式,这被称为接口。接口是一个没有功能的类;它只有纯虚函数。接口的目的是定义一种行为。从接口派生的具体类必须提供接口上所有方法的实现,因此这使得接口成为一种契约。实现接口的对象的用户保证对象将实现接口的所有方法。接口编程将行为与实现解耦。客户端代码只对行为感兴趣,他们对提供接口的实际类不感兴趣。

例如,一个IPrint接口可以访问打印文档的行为(设置页面大小、方向、副本数量,并告诉打印机打印文档)。IScan接口可以访问扫描纸张的行为(分辨率、灰度或彩色,以及旋转和裁剪等调整)。这两个接口是两种不同的行为。客户端代码将使用IPrint,如果要打印文档,或者使用IScan接口指针,如果要扫描文档。这样的客户端代码不在乎是实现了IPrint接口的printer对象,还是实现了IPrintIScan接口的printer_scanner对象。传递给IPrint*接口指针的客户端代码保证可以调用每个方法。

在下面的代码中,我们定义了IPrint接口(define使得我们更清楚地定义抽象类作为接口):

    #define interface struct 

    interface IPrint 
    { 
        virtual void set_page(/*size, orientation etc*/) = 0; 
        virtual void print_page(const string &str) = 0; 
    };

一个类可以实现这个接口:

    class inkjet_printer : public IPrint 
    { 
    public: 
        virtual void set_page(/*size, orientation etc*/) override 
        { 
            // set page properties 
        } 
        virtual void print_page(const string &str) override 
        { 
            cout << str << endl; 
        } 
    }; 

    void print_doc(IPrint *printer, vector<string> doc);

然后可以创建printer对象并调用该函数:

    inkjet_printer inkjet; 
    IPrint *printer = &inkjet; 
    printer->set_page(/*properties*/); 
    vector<string> doc {"page 1", "page 2", "page 3"}; 
    print_doc(printer, doc);

我们的喷墨打印机也是扫描仪,所以我们可以让它实现IScan接口:

    interface IScan 
    { 
        virtual void set_page(/*resolution etc*/) = 0; 
        virtual string scan_page() = 0; 
    };

inkject_printer类的下一个版本可以使用多重继承来实现这个接口,但请注意存在一个问题。该类已经实现了一个名为set_page的方法,由于打印机的页面属性将与扫描仪的页面属性不同,我们希望为IScan接口使用不同的方法。我们可以通过两种不同的方法来解决这个问题,并对它们的名称进行限定:

    class inkjet_printer : public IPrint, public IScan 
    { 
    public: 
        virtual void IPrint::set_page(/*etc*/) override { /*etc*/ } 
        virtual void print_page(const string &str) override 
        { 
            cout << str << endl; 
        } 
        virtual void IScan::set_page(/*etc*/) override { /*etc*/ } 
        virtual string scan_page() override 
        { 
            static int page_no; 
            string str("page "); 
            str += to_string(++page_no); 
            return str; 
        } 
    }; 

    void scan_doc(IScan *scanner, int num_pages);

现在,我们可以获取inkjet对象上的IScan接口,并将其作为扫描仪调用:

    inkjet_printer inkjet; 
    IScan *scanner = &inkjet; 
    scanner->set_page(/*properties*/); 
    scan_doc(scanner, 5);

由于inkject_printer类从IPrinterIScan接口派生,您可以通过dynamic_cast运算符获得一个接口指针,并通过它转换为另一个接口,因为这将使用 RTTI 来确保转换是可能的。因此,假设您有一个IScanner接口指针,您可以测试是否可以将其转换为IPrint接口指针:

    IPrint *printer = dynamic_cast<IPrint*>(scanner); 
    if (printer != nullptr) 
    { 
        printer->set_page(/*properties*/); 
        vector<string> doc {"page 1", "page 2", "page 3"}; 
        print_doc(printer, doc); 
    }

实际上,dynamic_cast运算符被用于在指向的对象上请求一个接口指针,如果另一个接口表示的行为在该对象上不可用。

接口是一种契约;一旦您定义了它,就不应该再更改。这并不限制您更改类。事实上,这就是使用接口的优势,因为类的实现可以完全改变,但只要它继续实现客户端代码使用的接口,类的用户就可以继续使用类(尽管需要重新编译)。有时您会发现您定义的接口是不足够的。也许有一个参数被错误地类型化,您需要修复,或者您需要添加额外的功能。

例如,假设您想要告诉打印机对象一次打印整个文档而不是一页一页地打印。要做到这一点,需要从需要更改的接口派生,并创建一个新的接口;接口继承:

    interface IPrint2 : IPrint 
    { 
        virtual void print_doc(const vector<string> &doc) = 0; 
    };

接口继承意味着IPrint2有三个方法,set_pageprint_pageprint_doc。由于IPrint2接口是IPrint接口,这意味着当您实现IPrint2接口时,您也实现了IPrint接口,因此需要更改类以从IPrint2接口派生以添加新功能:

 class inkjet_printer : public IPrint2, public IScan 
    { 
    public: 
 virtual void print_doc(const vector<string> &doc) override { 
            /* code*/
        } 
        // other methods 
    };

IPrint2接口上的另外两个方法已经存在于该类中,因为实现了IPrint接口。现在,客户端可以从该类的实例中获取IPrint指针和IPrint2指针。您已经扩展了类,但旧的客户端代码仍将编译通过。

微软的组件对象模型COM)将这个概念推进了一步。COM 基于接口编程,因此只能通过接口指针访问 COM 对象。额外的一步是,这段代码可以加载到您的进程中,使用动态加载库,或者加载到您的机器上的另一个进程中,或者加载到另一台机器上,由于使用接口编程,无论位置如何,都可以以完全相同的方式访问对象。

类关系

继承似乎是重用代码的理想方式:您可以以尽可能通用的方式编写代码,然后从基类派生一个类,并重用代码,必要时进行特化。然而,您会发现很多人反对这种做法。有些人会告诉您,继承是重用代码的最糟糕方式,您应该使用组合代替。实际上,情况介于两者之间:继承提供了一些好处,但不应将其视为最佳或唯一的解决方案。

设计类库时可能会走火入魔,有一个一般原则需要牢记:您写的代码越多,您(或其他人)就必须做的维护工作就越多。如果更改一个类,所有依赖它的其他类都将发生变化。

在最高级别,您应该注意避免的三个主要问题:

  • 僵化性:更改类太困难,因为任何更改都会影响太多其他类。

  • 脆弱性:更改类可能会导致其他类出现意外更改。

  • 不可移动性:很难重用类,因为它过于依赖其他类。

当类之间存在紧密耦合时就会出现这种情况。通常,您应该设计您的类以避免这种情况,接口编程是一个很好的方法,因为接口只是一种行为,而不是特定类的实例。

当您存在依赖反转时,就会出现这样的问题,即更高级别的代码使用组件时会依赖于较低级别组件的实现细节。如果您编写执行某些操作然后记录结果的代码,并且将记录到特定设备(比如cout对象)中,那么代码就会严格耦合并依赖于该记录设备,未来无法更改为其他设备。如果您通过接口指针来抽象功能,那么就会打破这种依赖,使代码能够在未来与其他组件一起使用。

另一个原则是,通常应该设计可扩展的类。继承是一种相当蛮力的扩展类的机制,因为您正在创建一个全新的类型。如果功能只需要进行细化,那么继承可能会过度。一种更轻量级的细化算法的方法是传递一个方法指针(或者一个函数对象),或者一个接口指针给类的方法,以便在适当的时候调用该方法来细化其工作方式。

例如,大多数排序算法要求您传递一个方法指针来执行对其正在排序的两个对象进行比较。排序机制是通用的,以最有效的方式对对象进行排序,但这是基于您告诉它如何对这两个对象进行排序。为每种类型编写一个新类是多余的,因为大多数算法保持不变。

使用混合类

混合技术允许您为类提供可扩展性,而不会出现组合的生命周期问题或原始继承的重量级方面。这里的想法是,您有一个具有特定功能的库,可以将其添加到对象中。一种方法是将其应用为具有public方法的基类,因此如果派生类公开从该类派生,它也将具有这些方法作为public方法。这很好地工作,除非功能要求派生类在这些方法中也执行某些功能,此时库的文档将要求派生类覆盖该方法,调用基类实现,并添加自己的代码以完成实现(基类方法可以在额外的派生类代码之前或之后调用,文档必须指定这一点)。迄今为止,在本章中我们已经看到这种方法被多次使用,这是一些旧的类库使用的技术,例如微软的基础类库MFC)。Visual C++使这变得更容易,因为它使用向导工具生成 MFC 代码,并且有关开发人员应该在何处添加其代码的注释。

这种方法的问题在于,它要求从基类派生的开发人员实现特定的代码并遵循规则。

开发人员可能会编写可以编译和运行的代码,但由于未按照期望的规则编写,因此在运行时会出现错误的行为。

混合类将这个概念颠倒过来。开发人员不再从库提供的基类派生并扩展提供的功能,而是库提供的混合类从开发人员提供的类派生。这解决了几个问题。首先,开发人员必须按照文档要求提供特定的方法,否则混合类(将使用这些方法)将无法编译。编译器强制执行类库作者的规则,要求使用库的开发人员提供特定的代码。其次,混合类上的方法可以在需要的地方调用基类方法(由开发人员提供)。使用类库的开发人员不再提供关于他们的代码如何开发的详细说明,除了他们必须实现某些方法。

那么,如何实现这一点呢?类库作者不知道客户端开发人员将编写的代码,也不知道客户端开发人员将编写的类的名称,因此他们无法从这样的类派生。C++允许您通过模板参数提供类型,以便在编译时使用该类型实例化类。对于混合类,通过模板参数传递的类型是将用作基类的类型的名称。开发人员只需提供一个具有特定方法的类,然后使用他们的类作为模板参数创建混合类的特化:

    // Library code 
    template <typename BASE> 
    class mixin : public BASE 
    { 
    public: 
        void something() 
        { 
            cout << "mixin do something" << endl; 
            BASE::something(); 
            cout << "mixin something else" << endl; 
        } 
    }; 

    // Client code to adapt the mixin class 
    class impl  
    { 
    public: 
        void something() 
        { 
            cout << "impl do something" << endl; 
        } 
    };

这个类是这样使用的:

    mixin<impl> obj; 
    obj.something();

正如你所看到的,mixin类实现了一个名为something的方法,并调用了一个名为something的基类方法。这意味着使用混合类功能的客户端开发人员必须实现一个具有相同名称和原型的方法,否则无法使用混合类。编写impl类的客户端开发人员不知道他们的代码将如何被使用,只知道他们必须提供具有特定名称和原型的方法。在这种情况下,mixin::something方法在提供的功能之间调用基类方法,impl类的编写者不需要知道这一点。这段代码的输出如下:

    mixin do something
impl do something
mixin something else

这表明mixin类可以在它认为合适的地方调用impl类。impl类只需提供功能;mixin类确定如何使用它。实际上,只要实现了具有正确名称和原型的方法的任何类都可以作为mixin类的模板的参数提供-甚至另一个混合类!

    template <typename BASE> 
    class mixin2 : public BASE 
    { 
    public: 
        void something() 
        { 
            cout << "mixin2 do something" << endl; 
            BASE::something(); 
            cout << "mixin2 something else" << endl; 
        } 
    };

这可以这样使用:

    mixin2< mixin<impl> > obj; 
    obj.something();

结果将如下所示:

    mixin2 do something
mixin do something
impl do something
mixin something else 
mixin2 something else

请注意,mixinmixin2类除了实现适当的方法之外,对彼此一无所知。

由于没有提供模板参数的类型,混合类有时被称为抽象子类。

如果基类只有一个默认构造函数,那么这将起作用。如果实现需要另一个构造函数,那么混合类必须知道调用哪个构造函数,并且必须具有适当的参数。另外,如果链接混合类,那么它们将通过构造函数耦合在一起。解决这个问题的一种方法是使用两阶段构造,也就是说,提供一个命名方法(比如init)用于在构造后初始化对象的数据成员。混合类仍将使用它们的默认构造函数创建,因此类之间不会有耦合,也就是说,mixin2类将不知道mixinimpl的数据成员:

    mixin2< mixin<impl> > obj; 
    obj.impl::init(/* parameters */);  // call impl::init 
    obj.mixin::init(/* parameters */); // call mixin::init 
    obj.init(/* parameters */);        // call mixin2::init 
    obj.something();

这是因为只要限定方法的名称,就可以调用公共基类方法。这三个init方法中的参数列表可以不同。然而,这确实带来了一个问题,即客户端现在必须初始化链中的所有基类。

这是微软的ActiveX 模板库ATL)(现在是 MFC 的一部分)用来提供标准 COM 接口的实现的方法。

使用多态

在以下示例中,我们将创建模拟 C++开发团队的代码。该代码将使用接口来解耦类,以便可以更改类使用的服务而不更改该类。在这个模拟中,我们有一个经理管理一个团队,因此经理的一个属性是他们的团队。此外,每个工人,无论是经理还是团队成员,都有一些共同的属性和行为--他们都有一个名称和工作职位,他们都做某种工作。

为该章节创建一个文件夹,在该文件夹中创建一个名为team_builder.cpp的文件,并且由于此应用程序将使用vector、智能指针和文件,因此在文件顶部添加以下行:

    #include <iostream> 
    #include <string> 
    #include <vector> 
    #include <fstream> 
    #include <memory> 
    using namespace std;

应用程序将具有命令行参数,但目前只需提供一个空的main函数副本:

    int main(int argc, const char *argv[]) 
    { 
        return 0;  
    }

我们将定义接口,因此在main函数之前添加以下内容:

    #define interface struct

这只是一种语法糖,但它使代码更易读,以显示抽象类的目的。在此之下,添加以下接口:

    interface IWork 
    { 
        virtual const char* get_name() = 0; 
        virtual const char* get_position() = 0; 
        virtual void do_work() = 0; 
    }; 

    interface IManage 
    { 
        virtual const vector<unique_ptr<IWork>>& get_team() = 0; 
        virtual void manage_team() = 0; 
    }; 

    interface IDevelop  
    { 
        virtual void write_code() = 0; 
    };

所有工人都将实现第一个接口,该接口允许访问他们的名称和工作职位以及告诉他们做一些工作的方法。我们将定义两种类型的工人,一个通过安排时间来管理团队的经理和编写代码的开发人员。经理有一个IWork*指针的vector,由于这些指针将指向在自由存储上创建的对象,因此vector成员是包装这些指针的智能指针。这意味着经理维护这些对象的生命周期:只要经理对象存在,他们的团队也会存在。

首先要做的是创建一个助手类,该类执行工人的基本工作。稍后在示例中将会看到这一点。该类将实现IWork接口:

    class worker : public IWork 
    { 
        string name; 
        string position; 
    public: 
        worker() = delete; 
        worker(const char *n, const char *p) : name(n), position(p) {} 
        virtual ~worker() {} 
        virtual const char* get_name() override  
        { return this->name.c_str(); } 
        virtual const char* get_position() override  
        { return this->position.c_str(); } 
        virtual void do_work() override { cout << "works" << endl; } 
    };

必须使用名称和工作职位创建一个worker对象。我们还将为经理创建一个助手类:

    class manager : public worker, public IManage 
    { 
        vector<unique_ptr<IWork>> team; 
    public: 
        manager() = delete; 
        manager(const char *n, const char* p) : worker(n, p) {} 
        const vector<unique_ptr<IWork>>& get_team() { return team; } 
        virtual void manage_team() override  
        { cout << "manages a team" << endl; } 
        void add_team_member(IWork* worker) 
        { team.push_back(unique_ptr<IWork>(worker)); } 
        virtual void do_work() override { this->manage_team(); } 
    };

请注意,do_work方法是根据虚函数manage_team实现的,这意味着派生类只需要实现manage_team方法,因为它将从其父类继承do_work方法,并且方法分派将意味着调用正确的方法。类的其余部分很简单,但请注意构造函数调用基类构造函数以初始化名称和工作职位(毕竟,经理也是工人),并且manager类有一个函数来使用智能指针将项目添加到团队中。

为了测试这一点,我们需要创建一个管理开发人员的manager类:

    class project_manager : public manager 
    { 
    public: 
        project_manager() = delete; 
        project_manager(const char *n) : manager(n, "Project Manager") 
        {} 
        virtual void manage_team() override  
        { cout << "manages team of developers" << endl; } 
    };

这覆盖了对基类构造函数的调用,传递了项目经理的名称和描述工作的文字。该类还覆盖了manage_team以说明经理实际上做了什么。在这一点上,您应该能够创建一个project_manager并向他们的团队添加一些成员(使用worker对象,您将在一会儿创建开发人员)。将以下内容添加到main函数中:

    project_manager pm("Agnes"); 
    pm.add_team_member(new worker("Bill", "Developer")); 
    pm.add_team_member(new worker("Chris", "Developer")); 
    pm.add_team_member(new worker("Dave", "Developer")); 
    pm.add_team_member(new worker("Edith", "DBA"));

这段代码将编译,但运行时不会有输出,因此创建一个方法来打印经理的团队:

    void print_team(IWork *mgr) 
    { 
        cout << mgr->get_name() << " is "  
             << mgr->get_position() << " and "; 
        IManage *manager = dynamic_cast<IManage*>(mgr); 
        if (manager != nullptr) 
        { 
            cout << "manages a team of: " << endl; 
            for (auto team_member : manager->get_team()) 
            { 
                cout << team_member->get_name() << " " 
                     << team_member->get_position() << endl; 
            } 
        } 
        else { cout << "is not a manager" << endl; } 
    }

此函数显示了接口有多么有用。您可以将任何工人传递给该函数,并且它将打印出与所有工人相关的信息(名称和工作职位)。然后,它通过请求IManage接口询问对象是否是经理。如果对象实现了此接口,函数只能获取经理的行为(在这种情况下,拥有一个团队)。在main函数的最后,在对program_manager对象的最后一次调用之后,调用此函数:

    print_team(&pm)

编译此代码(记得使用/EHsc开关)并运行代码。您将获得以下输出:

 Agnes is Project Manager and manages a team of:
 Bill Developer
 Chris Developer
 Dave Developer
 Edith DBA

现在我们将添加多态性,所以在print_team函数之前添加以下类:

    class cpp_developer : public worker, public IDevelop 
    { 
    public: 
        cpp_developer() = delete; 
        cpp_developer(const char *n) : worker(n, "C++ Dev") {} 
        void write_code() { cout << "Writing C++ ..." << endl; } 
        virtual void do_work() override { this->write_code(); } 
    }; 

    class database_admin : public worker, public IDevelop 
    { 
    public: 
        database_admin() = delete; 
        database_admin(const char *n) : worker(n, "DBA") {} 
        void write_code() { cout << "Writing SQL ..." << endl; } 
        virtual void do_work() override { this->write_code(); } 
    };

您可以更改main函数,以便使用cpp_developer代替worker对象,用于 Bill、Chris 和 Dave,以及使用database_admin代替 Edith:

    project_manager pm("Agnes"); 
    pm.add_team_member(new cpp_developer("Bill")); 
    pm.add_team_member(new cpp_developer("Chris")); 
    pm.add_team_member(new cpp_developer("Dave")); 
    pm.add_team_member(new database_admin("Edith")); 
    print_team(&pm);

现在,您可以编译和运行代码,看到不仅可以将不同类型的对象添加到经理的团队中,而且还可以通过IWork接口打印出相应的信息。

下一个任务是添加代码来序列化和反序列化这些对象。序列化意味着将对象的状态(和类型信息)写入流,反序列化将获取该信息并创建具有指定状态的适当类型的新对象。为此,每个对象必须具有一个构造函数,该构造函数接受一个指向反序列化器对象的接口指针,并且构造函数应调用此接口以提取正在创建的对象的状态。此外,这样的类应实现一种方法,将对象的状态序列化并写入序列化器对象。让我们首先看一下序列化。在文件顶部添加以下接口:

    #define interface struct 

 interface IWork; 
    // forward declaration interface ISerializer { virtual void write_string(const string& line) = 0; virtual void write_worker(IWork *worker) = 0; virtual void write_workers ( const vector<unique_ptr<IWork>>& workers) = 0; }; interface ISerializable { virtual void serialize(ISerializer *stm) = 0; };

需要前向声明,因为ISerializer接口使用IWork接口。第一个接口ISerializer由提供序列化服务的对象实现。这可以基于文件、网络套接字、数据库或任何您想要用于存储对象的东西。底层存储机制对于此接口的用户来说并不重要;重要的是接口可以存储字符串,并且可以使用IWork接口指针或此类对象的集合存储整个对象。

可以序列化的对象必须实现ISerializable接口,该接口具有一个方法,该方法接受提供序列化服务的对象的接口指针。在接口的定义之后,添加以下类:

    class file_writer : public ISerializer 
    { 
        ofstream stm; 
    public: 
        file_writer() = delete; 
        file_writer(const char *file) { stm.open(file, ios::out); } 
        ~file_writer() { close(); } 
        void close() { stm.close(); } 
        virtual void write_worker(IWork *worker) override 
        { 
            ISerializable *object = dynamic_cast<ISerializable*>(worker); 
            if (object != nullptr) 
            { 
                ISerializer *serializer = dynamic_cast<ISerializer*>(this); 
                serializer->write_string(typeid(*worker).raw_name()); 
         object->serialize(serializer); 
            } 
        } 
        virtual void write_workers( 
        const vector<unique_ptr<IWork>>& workers) override 
        { 
            write_string("[["); 
            for (const unique_ptr<IWork>& member : workers) 
            { 
                write_worker(member.get()); 
            } 
            write_string("]]"); // end marker of team 
        } 
        virtual void write_string(const string& line) override 
        { 
            stm << line << endl; 
        } 
    };

该类为文件提供了ISerializer接口,因此write_string方法使用ifstream插入运算符将字符串写入文件的单行。write_worker方法将 worker 对象写入文件。为此,它首先询问 worker 对象是否可以通过将IWork接口转换为ISerializable接口来对自身进行序列化。如果 worker 对象实现了此接口,序列化器可以通过将ISerializer接口指针传递给 worker 对象的serialize方法来要求 worker 对象对自身进行序列化。工作对象决定必须序列化的信息。工作对象除了ISerializer接口之外对file_writer类一无所知,而file_writer类除了知道它实现了IWorkISerializable接口之外对 worker 对象一无所知。

如果 worker 对象是可序列化的,write_worker方法的第一件事是获取有关对象的类型信息。IWork接口将位于一个类(project_managercpp_developerdatabase_admin)上,因此解引用指针将使typeid运算符访问类类型信息。我们将原始类型名称存储在序列化器中,因为它很紧凑。一旦类型信息被序列化,我们通过调用其ISerializable接口上的serialize方法要求对象对自身进行序列化。worker 对象将存储任何它想要的信息。

manager 对象需要序列化他们的团队,他们通过将 worker 对象的集合传递给write_workers方法来实现这一点。这表明被序列化的对象是一个数组,通过在两个标记[[]]之间写入它们来表示。请注意,因为容器有unique_ptr对象,所以没有复制构造函数,因为那将意味着共享所有权。所以我们通过索引操作符访问项目,这将给我们一个对容器中unique_ptr对象的引用。

现在,对于每个可以被序列化的类,你必须从ISerializable派生出类,并实现serialize方法。类继承树意味着每个 worker 类型的类都从worker类派生,所以我们只需要这个类从ISerializable接口派生:

    class worker : public IWork, public ISerializable

约定是一个类只序列化自己的状态,并委托给它的基类来序列化基类对象。在继承树的顶部是worker类,所以在这个类的底部添加以下接口方法:

    virtual void serialize(ISerializer *stm) override 
    { 
        stm->write_string(name); 
        stm->write_string(position); 
    }

这只是将名字和工作职位序列化到序列化器中。请注意,worker 对象不知道序列化器将如何处理这些信息,也不知道哪个类提供了ISerializer接口。

cpp_developer类的底部,添加这个方法:

    virtual void serialize(ISerializer* stm) override 
    { worker::serialize(stm); }

cpp_developer类没有任何额外的状态,所以它将序列化委托给它的父类。如果开发者类有一个状态,那么它将在序列化基对象之后序列化这个状态。在database_admin类的底部添加完全相同的代码。

project_manager类也调用了它的基类,但这是manager,所以在project_manager类的底部添加以下内容:

    virtual void serialize(ISerializer* stm) override 
    { manager::serialize(stm); }

manager::serialize更复杂,因为这个类有应该被序列化的状态:

    virtual void serialize(ISerializer* stm) override 
    { 
        worker::serialize(stm); 
        stm->write_workers(this->team); 
    }

第一步是序列化基类:一个worker对象。然后代码序列化manager对象的状态,这意味着通过将这个集合传递给序列化器来序列化team数据成员。

为了能够测试序列化,创建一个方法在main方法之上,并将project_manager的代码移到新方法中,并添加代码来序列化对象:

    void serialize(const char* file) 
    { 
        project_manager pm("Agnes"); 
        pm.add_team_member(new cpp_developer("Bill")); 
        pm.add_team_member(new cpp_developer("Chris")); 
        pm.add_team_member(new cpp_developer("Dave")); 
        pm.add_team_member(new database_admin("Edith")); 
        print_team(&pm); 

        cout << endl << "writing to " << file << endl; 

        file_writer writer(file); 
        ISerializer* ser = dynamic_cast<ISerializer*>(&writer); 
        ser->write_worker(&pm); 
        writer.close(); 
    }

上述代码创建了一个file_writer对象用于指定的文件,获取了该对象上的ISerializer接口,然后序列化了项目经理对象。如果你有其他团队,你可以在关闭writer对象之前将它们序列化到文件中。

main函数将接受两个参数。第一个是文件的名字,第二个是一个字符,rw(读或写文件)。添加以下代码来替换main函数:

    void usage() 
    { 
        cout << "usage: team_builder file [r|w]" << endl; 
        cout << "file is the name of the file to read or write" << endl; 
        cout << "provide w to file the file (the default)" << endl; 
        cout << "        r to read the file" << endl; 
    } 

    int main(int argc, char* argv[]) 
    { 
        if (argc < 2) 
        { 
            usage(); 
            return 0; 
        } 

        bool write = true; 
        const char *file = argv[1]; 
        if (argc > 2) write = (argv[2][0] == 'w'); 

        cout << (write ? "Write " : "Read ") << file << endl << endl; 

        if (write) serialize(file); 
        return 0; 
    }

现在你可以编译这段代码并运行它,给出一个文件的名字:

    team_builder cpp_team.txt w

这将创建一个名为cpp_team.txt的文件,其中包含关于团队的信息;在命令行中输入**type cpp_team.txt**

    .?AVproject_manager@@ 
    Agnes 
    Project Manager 
    [[ 
    .?AVcpp_developer@@ 
    Bill 
    C++ Dev 
    .?AVcpp_developer@@ 
    Chris 
    C++ Dev 
    .?AVcpp_developer@@ 
    Dave 
    C++ Dev 
    .?AVdatabase_admin@@ 
    Edith 
    DBA 
    ]]

这个文件不是为人类阅读而设计的,但是你可以看到,每一行都有一条信息,每个序列化对象都在类的类型之前。

现在你将编写代码来反序列化一个对象。代码需要一个类来读取序列化数据并返回 worker 对象。这个类与序列化器类紧密耦合,但应该通过接口访问,以便不与 worker 对象耦合。在ISerializable接口的声明之后,添加以下内容:

    interface IDeserializer 
    { 
        virtual string read_string() = 0; 
        virtual unique_ptr<IWork> read_worker() = 0; 
        virtual void read_workers(vector<unique_ptr<IWork>>& team) = 0; 
    };

第一个方法获取序列化字符串,另外两个方法获取单个对象和对象集合。由于这些 worker 对象将在自由存储上创建,这些方法使用智能指针。每个类都可以对自身进行序列化,因此现在您将使每个可序列化的类能够对自身进行反序列化。为实现ISerializable的每个类添加一个接受IDeserializer接口指针的构造函数。从worker类开始;添加以下公共构造函数:

    worker(IDeserializer *stm) 
    { 
        name = stm->read_string(); 
        position = stm->read_string(); 
    }

本质上,这颠倒了serialize方法的操作,它按照传递给序列化器的顺序从反序列化器中读取名称和位置字符串。由于cpp_developerdatabase_admin类没有状态,它们在调用基类构造函数之外不需要进行任何其他反序列化工作。例如,向cpp_developer类添加以下公共构造函数:

    cpp_developer(IDeserializer* stm) : worker(stm) {}

database_admin类添加类似的构造函数。

经理们有状态,因此反序列化它们需要更多的工作。在manager类中添加以下内容:

    manager(IDeserializer* stm) : worker(stm) 
    { stm->read_workers(this->team); }

初始化列表构造了基类,在此之后,构造函数通过在IDeserializer接口上调用read_workers来将team集合初始化为零个或多个 worker 对象。最后,project_manager类派生自manager类,但不添加额外的状态,因此添加以下构造函数:

    project_manager(IDeserializer* stm) : manager(stm) {}

现在,每个可序列化的类都可以对自身进行反序列化,下一步是编写读取文件的反序列化器类。在file_writer类之后,添加以下内容(注意有两个方法没有内联实现):

    class file_reader : public IDeserializer 
    { 
        ifstream stm; 
    public: 
        file_reader() = delete; 
        file_reader(const char *file) { stm.open(file, ios::in); } 
        ~file_reader() { close(); } 
        void close() { stm.close(); } 
        virtual unique_ptr<IWork> read_worker() override; 
        virtual void read_workers( 
            vector<unique_ptr<IWork>>& team) override; 
        virtual string read_string() override 
        { 
            string line; 
            getline(stm, line); 
            return line; 
        } 
    };

构造函数打开指定的文件,析构函数关闭文件。read_string接口方法从文件中读取一行并将其作为字符串返回。主要工作在这里未实现的两个接口方法中进行。read_workers方法将读取一组IWork对象并将它们放入传递的集合中。此方法将为文件中的每个对象调用read_worker方法并将它们放入集合中,因此读取文件的主要工作在此方法中进行。read_worker方法是该类唯一与可序列化类有耦合的部分,因此必须在 worker 类的定义下定义。在serialize全局函数上方添加以下内容:

    unique_ptr<IWork> file_reader::read_worker() 
    { 
    } 
    void file_reader::read_workers(vector<unique_ptr<IWork>>& team) 
    { 
        while (true) 
        { 
            unique_ptr<IWork> worker = read_worker(); 
            if (!worker) break; 
            team.push_back(std::move(worker)); 
        } 
    }

read_workers方法将使用read_worker方法从文件中读取每个对象,该方法将每个对象以unique_ptr对象的形式返回。我们希望将此对象放入容器中,但由于指针应该具有独占所有权,因此我们需要将所有权移动到容器中的对象中。有两种方法可以做到这一点。第一种方法是简单地将read_worker的调用作为push_back的参数。read_worker方法返回一个临时对象,即右值,因此编译器在创建容器中的对象时将使用移动语义。我们不这样做是因为read_worker方法可能返回nullptr(我们希望进行测试),因此我们创建一个新的unique_ptr对象(移动语义将所有权传递给此对象),一旦我们测试了这个对象不是nullptr,我们调用标准库函数move将对象复制到容器中。

如果read_worker方法读取数组的结束标记,则返回nullptr,因此read_workers方法循环读取每个 worker 并将它们放入集合,直到返回nullptr

像这样实现read_worker方法:

    unique_ptr<IWork> file_reader::read_worker() 
    { 
        string type = read_string(); 
        if (type == "[[") type = read_string(); 
        if (type == "]]") return nullptr; 
        if (type == typeid(worker).raw_name()) 
        { 
            return unique_ptr<IWork>( 
            dynamic_cast<IWork*>(new worker(this))); 
        }    
        return nullptr; 
    }

第一行从文件中读取工作对象的类型信息,以便知道要创建什么对象。由于文件将有标记来指示团队成员的数组,代码必须检测这些标记。如果检测到数组的开始,标记字符串将被忽略,并且将读取下一行以获取团队中第一个对象的类型。如果读取到结束标记,那么这就是数组的结束,所以返回nullptr

这里显示了一个worker对象的代码。if语句用于检查类型字符串是否与worker类的原始名称相同。如果是,则我们必须创建一个worker对象,并请求它通过调用接受IDeserializer指针的构造函数来反序列化自己。worker对象在自由存储上创建,并调用dynamic_cast运算符来获取IWork接口指针,然后用它来初始化智能指针对象。unique_ptr的构造函数是explicit的,所以您必须调用它。现在为所有其他可序列化的类添加类似的代码:

    if (type == typeid(project_manager).raw_name()) 
    { 
        return unique_ptr<IWork>( 
        dynamic_cast<IWork*>(new project_manager(this))); 
    } 
    if (type == typeid(cpp_developer).raw_name()) 
    { 
        return unique_ptr<IWork>( 
        dynamic_cast<IWork*>(new cpp_developer(this))); 
    } 
    if (type == typeid(database_admin).raw_name()) 
    { 
        return unique_ptr<IWork>( 
        dynamic_cast<IWork*>(new database_admin(this))); 
    }

最后,您需要创建一个file_reader并反序列化一个文件。在serialize函数之后,添加以下内容:

    void deserialize(const char* file) 
    { 
        file_reader reader(file); 
        while (true) 
        { 
            unique_ptr<IWork> worker = reader.read_worker(); 
            if (worker) print_team(worker.get()); 
            else break; 
        } 
        reader.close(); 
    }

这段代码简单地创建了一个基于文件名的file_reader对象,然后从文件中读取每个工作对象并打印出对象,如果是project_manager,则打印出他们的团队。最后,在main函数中添加一行来调用这个函数:

    cout << (write ? "Write " : "Read ") << file << endl << endl; 
    if (write) serialize(file); 
 else deserialize(file);

现在您可以编译代码并使用它来读取序列化文件,如下所示:

    team_builder cpp_team.txt r

(注意 r 参数。)代码应该打印出你序列化到文件中的对象。

前面的例子表明,您可以编写可序列化的对象,而这些对象并不知道用于序列化它们的机制。如果您想使用不同的机制(例如 XML 文件或数据库),您无需更改任何工作类。相反,您可以编写一个适当的类来实现ISerializer接口和IDeserailizer接口。如果您需要创建另一个工作类,您只需要修改read_worker方法以反序列化该类型的对象。

总结

在本章中,您看到了如何使用 C继承来重用代码,并在对象之间提供 is-a 关系。您还看到了如何使用这个特性来实现多态性,相关的对象可以被视为具有相同的行为,同时仍然保持调用每个对象的方法的能力,以及将行为组合在一起的接口。在下一章中,您将看到 C标准库的特性以及它提供的各种实用类。

第八章:使用标准库容器

标准库提供了几种类型的容器;每个都是通过模板类提供的,以便容器的行为可以用于任何类型的项目。有顺序容器的类,其中容器中项目的顺序取决于插入容器中的项目的顺序。还有排序和未排序的关联容器,它们将值与键关联起来,随后使用键访问该值。

虽然它们本身不是容器,在本章中我们还将介绍两个相关的类:pair将两个值链接在一个对象中,tuple可以在一个对象中保存一个或多个值。

使用对和元组

在许多情况下,您会希望将两个项目关联在一起;例如,关联容器允许您创建一种数组类型,其中除了数字以外的项目被用作索引。<utility>头文件包含一个名为pair的模板类,它有两个名为firstsecond的数据成员。

    template <typename T1, typename T2> 
    struct pair 
    { 
        T1 first; 
        T2 second; 
        // other members 
    };

由于该类是模板化的,这意味着您可以关联任何项目,包括指针或引用。访问成员很简单,因为它们是公共的。您还可以使用get模板化函数,因此对于pair对象p,您可以调用get<0>(p)而不是p.first。该类还具有复制构造函数,因此您可以从另一个对象创建对象,并且移动构造函数。还有一个名为make_pair的函数,它将从参数中推断出成员的类型:

    auto name_age = make_pair("Richard", 52);

要小心,因为编译器将使用它认为最合适的类型;在这种情况下,创建的pair对象将是pair<const char*,int>,但如果您希望first项目是一个string,使用构造函数会更简单。您可以比较pair对象;比较是在第一个成员上执行的,只有在它们相等时才会比较第二个:

    pair <int, int> a(1, 1); 
    pair <int, int> a(1, 2); 
    cout << boolalpha; 
    cout << a << " < " << b << " " << (a < b) << endl;

参数可以是引用:

    int i1 = 0, i2 = 0; 
    pair<int&, int&> p(i1, i2); 
    ++p.first; // changes i1

make_pair函数将从参数中推断出类型。编译器无法区分变量和对变量的引用。在 C++11 中,您可以使用ref函数(在<functional>中)指定pair将用于引用:

    auto p2 = make_pair(ref(i1), ref(i2)); 
    ++p2.first; // changes i1

如果要从函数返回两个值,可以通过引用传递的参数来实现,但代码的可读性较差,因为您期望通过函数的返回而不是通过其参数来获得返回值。pair类允许您在一个对象中返回两个值。一个例子是<algorithm>中的minmax函数。这返回一个包含参数的pair对象,按最小值的顺序排列,并且有一个重载,您可以提供一个谓词对象,如果不应使用默认操作符<。以下将打印{10,20}

    auto p = minmax(20,10);  
    cout << "{" << p.first << "," << p.second << "}" << endl;

pair类关联两个项目。标准库提供了tuple类,它具有类似的功能,但由于模板是可变的,这意味着您可以具有任意数量的任何类型的参数。但是,数据成员不像pair中那样命名,而是通过模板化的get函数访问它们:

    tuple<int, int, int> t3 { 1,2,3 }; 
    cout << "{" 
        << get<0>(t3) << "," << get<1>(t3) << "," << get<2>(t3)  
        << "}" << endl; // {1,2,3}

第一行创建一个包含三个int项目的tuple,并使用初始化列表进行初始化(您可以使用构造函数语法)。然后通过访问对象中的每个数据成员来将tuple打印到控制台,使用get函数的一个版本,其中模板参数指示项目的索引。请注意,索引是模板参数,因此您无法使用变量在运行时提供它。如果这是您想要做的事情,那么这清楚地表明您需要使用诸如vector之类的容器。

get函数返回一个引用,因此可以用于更改项目的值。对于一个tuple t3,这段代码将第一个项目更改为42,第二个项目更改为99

    int& tmp = get<0>(t3); 
    tmp = 42; 
    get<1>(t3) = 99;

您还可以使用tie函数一次提取所有项目:

    int i1, i2, i3; 
    tie(i1, i2, i3) = t3; 
    cout << i1 << "," << i2 << "," << i3 << endl;

tie函数返回一个tuple,其中每个参数都是引用,并初始化为您传递的参数的变量。如果您这样写,以前的代码更容易理解:

    tuple<int&, int&, int&> tr3 = tie(i1, i2, i3); 
    tr3 = t3;

可以从pair对象创建tuple对象,因此也可以使用tie函数从pair对象中提取值。

有一个名为make_tuple的辅助函数,它将推断参数的类型。与make_pair函数一样,您必须谨慎推断,因此浮点数将被推断为double,整数将是int。如果要使参数成为特定变量的引用,可以使用ref函数或cref函数来获得const引用。

只要项目数量相等且类型等效,就可以比较tuple对象。如果tuple对象具有不同数量的项目,或者一个tuple对象的项目类型无法转换为另一个tuple对象的项目类型,则编译器将拒绝编译tuple对象的比较。

容器

标准库容器允许您将相同类型的零个或多个项目组合在一起,并通过迭代器顺序访问它们。每个这样的对象都有一个begin方法,该方法返回一个迭代器对象到第一个项目,并且一个end函数,该函数返回容器中最后一个项目之后的迭代器对象。迭代器对象支持类似指针的算术运算,因此end() - begin()将给出容器中的项目数。所有容器类型都将实现empty方法来指示容器中是否没有项目,并且(除了forward_listsize方法是容器中的项目数。您可能会尝试通过容器进行迭代,就像它是一个数组一样:

    vector<int> primes{1, 3, 5, 7, 11, 13}; 
    for (size_t idx = 0; idx < primes.size(); ++idx)  
    { 
        cout << primes[idx] << " "; 
    } 
    cout << endl;

问题在于并非所有容器都允许随机访问,如果决定使用另一个容器更有效,则必须更改容器的访问方式。如果要使用模板编写通用代码,这段代码也不起作用。最好使用迭代器编写以前的代码:

    template<typename container> void print(container& items) 
    { 
        for (container::iterator it = items.begin();  
        it != items.end(); ++it) 
        { 
            cout << *it << " "; 
        } 
        cout << endl; 
    }

所有容器都有一个名为iteratortypedef成员,该成员给出从begin方法返回的迭代器的类型。迭代器对象的行为类似于指针,因此可以使用解引用运算符获取迭代器引用的项目,并使用增量运算符移动到下一个项目。

除了vector之外的所有容器都保证即使删除其他元素,迭代器仍然有效。如果插入项目,则只有listsforward_lists和相关容器保证迭代器保持有效。迭代器将在以后更深入地讨论。

所有容器都必须具有一个名为swap的异常安全(无异常)方法,并且(有两个例外)它们必须具有事务语义;也就是说,操作必须成功或失败。如果操作失败,则容器的状态与调用操作之前相同。对于每个容器,在进行多元素插入时,此规则会放宽。例如,如果使用迭代器范围一次插入多个项目,并且插入失败了范围中的一个项目,则该方法将无法撤消先前的插入。

重要的是要指出,对象被复制到容器中,因此放入容器中的对象的类型必须具有复制和复制赋值运算符。此外,请注意,如果将派生类对象放入需要基类对象的容器中,则复制将切割对象,这意味着与派生类有关的任何内容都将被删除(数据成员和虚方法指针)。

序列容器

序列容器存储一系列项目以及它们存储的顺序,并且当您使用迭代器访问它们时,项目将按照放入容器的顺序检索。创建容器后,可以使用库函数更改排序顺序。

列表

顾名思义,list对象是由双向链表实现的,其中每个项目都有一个链接到下一个项目和上一个项目。这意味着可以快速插入项目(就像第四章中的示例所示的那样,使用单链表),但是由于在链表中,项目只能访问其前面和后面的项目,因此无法使用[]索引运算符进行随机访问。

该类允许您通过构造函数提供值,或者可以使用成员方法。例如,assign方法允许您使用初始化列表一次填充容器,或者使用迭代器将范围填充到另一个容器中。您还可以使用push_backpush_front方法插入单个项目:

    list<int> primes{ 3,5,7 }; 
    primes.push_back(11); 
    primes.push_back(13); 
    primes.push_front(2); 
    primes.push_front(1);

第一行创建一个包含357list对象,然后将1113依次推到末尾,使得list包含{3,5,7,11,13}。然后代码将数字21推到前面,使得最终的list{1,2,3,5,7,11,13}。尽管名称如此,pop_frontpop_back方法只是删除列表前面或后面的项目,但不会返回该项目。如果要获取已删除的项目,必须首先通过frontback方法访问该项目:

    int last = primes.back(); // get the last item 
    primes.pop_back();        // remove it

clear方法将删除list中的所有项目,而erase方法将删除项目。有两个版本:一个带有标识单个项目的迭代器,另一个带有指示范围的两个迭代器。通过提供范围的第一个项目和范围之后的项目来指示范围。

    auto start = primes.begin(); // 1 
    start++;                     // 2 
    auto last = start;           // 2 
    last++;                      // 3 
    last++;                      // 5 
    primes.erase(start, last);   // remove 2 and 3

这是迭代器和标准库容器的一般原则;迭代器通过第一个项目和最后一个项目之后的项目来指示范围。remove方法将删除具有指定值的所有项目:

    list<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
    planck.remove(6);            // {2,0,7,0,0,4,0}

还有一个remove_if方法,它接受一个谓词,只有在谓词返回true时才会删除项目。同样,您可以使用迭代器将项目插入到列表中,并且该项目将在指定项目之前插入:

    list<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
    auto it = planck.begin(); 
    ++it; 
    ++it; 
    planck.insert(it, -1); // {6,6,-1,2,6,0,7,0,0,4,0}

您还可以指示该项目应在该位置插入多次(如果是这样,还可以提供多少个副本),并且可以提供要在一个位置插入的多个项目。当然,如果您传递的迭代器是通过调用begin方法获得的,则该项目将插入到list的开头。通过调用push_front方法也可以实现相同的效果。同样,如果迭代器是通过调用end方法获得的,则该项目将插入到list的末尾,这与调用push_back相同。

当您调用insert方法时,您提供一个对象,该对象将被复制到list中或移动到list中(通过右值语义)。该类还提供了几种emplace方法(emplaceemplace_frontemplace_back),它们将根据您提供的数据构造一个新对象,并将该对象插入list中。例如,如果您有一个可以从两个double值创建的point类,您可以将构造的point对象或通过提供两个doubleemplace一个point对象:

    struct point 
    { 
        double x = 0, y = 0; 
        point(double _x, double _y) : x(_x), y(_y) {} 
    }; 

    list<point> points; 
    point p(1.0, 1.0); 
    points.push_back(p); 
    points.emplace_back(2.0, 2.0);

创建list后,可以使用成员函数对其进行操作。swap方法接受一个合适的list对象作为参数,它将参数中的项目移动到当前对象中,并将当前list中的项目移动到参数中。由于list对象是使用链表实现的,因此此操作很快。

    list<int> num1 { 2,7,1,8,2,8 }; // digits of Euler's number 
    list<int> num2 { 3,1,4,5,6,8 }; // digits of pi 
    num1.swap(num2);

在此之后,代码num1将包含{3,1,4,5,6,8},而num2将包含{2,7,1,8,2,8},如下所示:

list将按照插入到容器中的顺序保存项目;但是,您可以通过调用sort方法对它们进行排序,默认情况下,将使用list容器中项目的<运算符按升序排序项目。您还可以传递一个函数对象进行比较操作。排序后,您可以通过调用reverse方法反转项目的顺序。两个排序的列表可以合并,这涉及从参数列表中获取项目并将它们插入到调用列表中,以此顺序:

    list<int> num1 { 2,7,1,8,2,8 }; // digits of Euler's number 
    list<int> num2 { 3,1,4,5,6,8 }; // digits of pi 
    num1.sort();                    // {1,2,2,7,8,8} 
    num2.sort();                    // {1,3,4,5,6,8} 
    num1.merge(num2);               // {1,1,2,2,3,4,5,6,7,8,8,8}

合并两个列表可能会导致重复项,可以通过调用unique方法来删除这些重复项:

    num1.unique(); // {1,2,3,4,5,6,7,8}

Forward list

正如其名称所示,forward_list类类似于list类,但它只允许从列表的前面插入和删除项目。这也意味着与该类一起使用的迭代器只能递增;编译器将拒绝允许您递减这样的迭代器。该类具有list方法的子集,因此它具有push_frontpop_frontemplace_front方法,但没有相应的_back方法。它还实现了一些其他方法,因为列表项只能以前向方式访问,这意味着插入将发生在现有项目之后,因此该类实现了insert_afteremplace_after

同样,您可以从列表的开头删除项目(pop_front)或在指定项目之后删除项目(erase_after),或者告诉类在列表中以前向方式迭代并删除具有特定值的项目(removeremove_if):

    forward_list<int> euler { 2,7,1,8,2,8 }; 
    euler.push_front(-1);       // { -1,2,7,1,8,2,8 } 
    auto it = euler.begin();    // iterator points to -1 
    euler.insert_after(it, -2); // { -1,-2,2,7,1,8,2,8 } 
    euler.pop_front();          // { -2,2,7,1,8,2,8 } 
    euler.remove_if([](int i){return i < 0;}); 
                                // { 2,7,1,8,2,8 }

在前面的代码中,euler用欧拉数的数字初始化,并将值-1推到前面。接下来,获得一个指向容器中第一个值的迭代器;也就是说,指向值-1的位置。在迭代器的位置之后插入值-2;也就是说,在值-1之后插入值-2。最后两行显示了如何删除项目;pop_front删除容器前面的项目,remove_if将删除满足谓词的项目(在本例中,当项目小于零时)。

Vector

vector类具有动态数组的行为;也就是说,可以对项目进行索引随机访问,并且随着插入更多项目,容器将增长。您可以使用初始化列表创建vector对象,并使用指定数量的项目的副本。您还可以通过传递指示容器中项目范围的迭代器来基于另一个容器中的值创建vector。您可以通过提供容量作为构造函数参数来创建具有预定大小的向量,并且将在容器中创建指定数量的默认项目。如果在以后的阶段,您需要指定容器大小,可以调用reserve方法指定最小大小或resize方法,这可能意味着删除多余的项目或根据现有vector对象是大于还是小于请求的大小来创建新项目。

当您向vector容器插入项目并且没有分配足够的内存时,容器将分配足够的内存。这将涉及分配新内存,将现有项目复制到新内存中,创建新项目,最后销毁旧副本的项目并释放旧内存。显然,如果您知道项目的数量,并且知道vector容器没有足够的空间来容纳它们而需要新的分配,您应该通过调用reserve方法指示需要多少空间。

除了构造函数之外,插入项目是很简单的。你可以使用push_back在末尾插入一个项目(假设不需要分配,这是一个快速操作),还有pop_back来移除最后一个项目。你还可以使用assign方法来清空整个容器并插入指定的项目(多个相同项目,项目的初始化列表,或者使用迭代器指定的另一个容器中的项目)。与list对象一样,你可以清空整个vector,在指定位置擦除项目,或者在指定位置插入项目。然而,没有等效的remove方法来移除具有特定值的项目。

使用vector类的主要原因是使用at方法或[]索引运算符进行随机访问:

   vector<int> distrib(10); // ten intervals 
   for (int count = 0; count < 1000; ++count) 
   { 
      int val = rand() % 10; 
      ++distrib[val]; 
   } 
   for (int i : distrib) cout << i << endl;

第一行创建了一个具有十个项目的vector,然后在循环中每次调用 C 运行时函数rand一千次,以获得 0 到 32767 之间的伪随机数。使用模运算来获得大约在 0 到 9 之间的随机数。然后将这个随机数用作distrib对象的索引,以选择指定的项目,然后递增。最后,分布被打印出来,正如你所期望的那样,这给出了每个项目大约 100 的值。

这段代码依赖于[]运算符返回对项目的引用,这就是为什么可以以这种方式递增项目。可以使用[]运算符读取和写入容器中的项目。容器通过beginend方法提供迭代器访问,以及(因为它们被容器适配器所需)frontback方法。

vector对象可以容纳具有复制构造函数和赋值运算符的任何类型,这意味着所有内置类型。就目前而言,bool项目的vector将是一种浪费内存,因为布尔值可以存储为单个位,并且编译器将把bool视为整数(32 位)。标准库为bool专门化了vector类,以更有效地存储项目。然而,尽管这个类乍一看像是一个好主意,问题在于,由于容器将布尔值存储为位,这意味着[]运算符不会返回对bool的引用(而是返回一个像bool一样行为的对象)。

如果你想保存布尔值并对其进行操作,那么只要在编译时知道有多少项目,bitset类可能是一个更好的选择。

Deque

名称deque意味着双端队列,这意味着它可以从两端增长,尽管你可以在中间插入项目,但这样做的代价更高。作为队列,这意味着项目是有序的,但是,因为项目可以从两端放入队列,所以顺序不一定是你将项目放入容器的顺序。

deque的接口类似于vector,因此你可以使用at函数和[]运算符进行迭代器访问和随机访问。与vector一样,你可以使用push_backpop_backback方法从deque容器的末尾访问项目,但与vector不同的是,你还可以使用push_frontpop_frontfront方法访问deque容器的前端。虽然deque类有方法允许你在容器内插入和擦除项目,并且resize,但这些都是昂贵的操作,如果你需要使用它们,那么你应该重新考虑使用这种容器类型。此外,deque类没有方法来预先分配内存,因此,当你向这个容器添加项目时,可能会导致内存分配。

关联容器

对于类似 C 的arrayvector,每个项目都与其数字索引相关联。在vector部分的一个示例中,索引提供了分布的十分位数,并且方便地,分布被分割成了十个数据的十分位数。

关联容器允许您提供非数字索引;这些是键,您可以将值与它们关联起来。当您将键值对插入容器时,它们将被排序,以便容器随后可以通过其键有效地访问值。通常,这个顺序对您来说不重要,因为您不会使用容器按顺序访问项目,而是会通过它们的键访问值。典型的实现将使用二叉树或哈希表,这意味着根据其键查找项目是一个快速操作。

对于有序容器,比如map,将在键和容器中现有键之间使用<(小于谓词)进行比较。默认谓词意味着比较键,如果是智能指针,那么将比较并用于排序的是智能指针对象,而不是它们包装的对象。在这种情况下,您将需要编写自己的谓词来执行适当的比较,并将其作为模板参数传递。

这意味着插入或删除项目通常是昂贵的,并且键被视为不可变,因此您不能为项目更改它。对于所有关联容器,没有删除方法,但有擦除方法。但是,对于那些保持项目排序的容器,擦除项目可能会影响性能。

有几种类型的关联容器,主要区别在于它们如何处理重复键以及发生的排序级别。map类具有按唯一键排序的键值对,因此不允许重复键。如果要允许重复键,则可以使用multimap类。set类本质上是一个键与值相同的映射,再次,不允许重复。multiset类允许重复。

在关联类中,键与值相同似乎有些奇怪,但将类包含在本节的原因是因为,与map类似,set类具有类似的接口来查找值。与map类似,set类在查找项目时速度很快。

地图和多地图

map容器存储两个不同的项目,一个键和一个值,并根据键维护项目的排序顺序。排序的map意味着快速定位项目。该类具有与其他容器相同的接口来添加项目:您可以通过构造函数将它们放入容器中,或者可以使用成员方法insertemplace。您还可以通过迭代器访问项目。当然,迭代器提供对单个值的访问,因此对于map来说,这将是一个具有键和值的pair对象。

    map<string, int> people; 
    people.emplace("Washington", 1789); 
    people.emplace("Adams", 1797); 
    people.emplace("Jefferson", 1801); 
    people.emplace("Madison", 1809); 
    people.emplace("Monroe", 1817); 

    auto it = people.begin(); 
    pair<string, int> first_item = *it; 
    cout << first_item.first << " " << first_item.second << endl;

map调用emplace将项目放入map中,其中键是string(总统的姓名),值是int(总统开始任期的年份)。然后,代码获取容器中第一个项目的迭代器,并通过解引用迭代器访问项目以给出pair对象。由于项目按排序顺序存储在map中,第一个项目将设置为"Adams"。您还可以将项目作为pair对象插入,无论是作为对象还是通过对另一个容器中的pair对象的迭代器使用insert方法。

大多数emplaceinsert方法将返回以下形式的pair对象,其中iterator类型与map相关:

    pair<iterator, bool>

您可以使用此对象来测试两件事。首先,bool指示插入是否成功(如果具有相同键的项目已经在容器中,则插入将失败)。其次,pairiterator部分要么指示新项目的位置,要么指示不会被替换的现有项目的位置(并且将导致插入失败)。

失败取决于等价而不是相等。如果存在一个具有等价于您要插入的项目的键的项目,则插入将失败。等价的定义取决于与map对象一起使用的比较器谓词。因此,如果map使用谓词comp,则两个项目ab之间的等价性是通过测试!comp(a,b) && !comp(b,a)来确定的。这与测试(a==b)不同。

假设先前的map对象,您可以这样做:

    auto result = people.emplace("Adams", 1825); 
    if (!result.second) 
       cout << (*result.first).first << " already in map" << endl;

result变量中的第二个项目用于测试插入是否成功,如果不成功,则第一个项目是指向pair<string,int>的迭代器,这是现有项目,代码对迭代器进行解引用以获取pair对象,然后打印出第一个项目,即键(在本例中是人的姓名)。

如果您知道项目应该放在map中的位置,则可以调用emplace_hint

    auto result = people.emplace("Monroe", 1817); 
    people.emplace_hint(result.first, "Polk", 1845);

在这里,我们知道PolkMonroe之后,所以我们可以将迭代器传递给Monroe作为提示。该类通过迭代器提供对项目的访问,因此您可以使用基于迭代器访问的范围for

    for (pair<string, int> p : people) 
    { 
        cout << p.first << " " << p.second << endl; 
    }

此外,还可以使用at方法和[]运算符访问单个项目。在两种情况下,如果找到具有提供的键的项目,则返回对项目值的引用。at方法和[]运算符在指定键没有项目的情况下的行为不同。如果键不存在,则at方法将抛出异常;如果[]运算符找不到指定的键,则将使用该键创建一个新项目,并调用值类型的默认构造函数。如果键存在,[]运算符将返回对该值的引用,因此您可以编写如下代码:

    people["Adams"] = 1825; 
    people["Jackson"] = 1829;

第二行的行为与您期望的一样:不会有一个键为Jackson的项目,所以map将创建一个具有该键的项目,通过调用值类型(int)的默认构造函数进行初始化(因此值被初始化为零),然后返回对该值的引用,该引用被赋予1829的值。然而,第一行将查找Adams,看到有一个项目,并返回对其值的引用,然后将其赋予1825的值。没有迹象表明项目的值已更改,而不是插入了一个新项目。在某些情况下,您可能希望出现这种行为,但这并不是这段代码的意图,显然,需要允许重复键(例如multimap)的关联容器。此外,在这两种情况下,都会搜索键,返回引用,然后执行赋值。请注意,虽然以这种方式插入项目是有效的,但在容器中放置一个新的键值对更有效,因为您不需要进行额外的赋值。

填充map后,可以使用以下方法搜索值:

  • at方法,传递一个键并返回该键的值的引用

  • []运算符,当传递一个键时,返回该键的值的引用

  • find函数将使用模板中指定的谓词(与稍后提到的全局find函数不同),并将为您提供对整个项目的迭代器作为pair对象

  • begin方法将为您提供对第一个项目的迭代器,end方法将为您提供对最后一个项目之后的迭代器

  • lower_bound方法返回一个迭代器,指向具有等于或大于您传递的键的键的项目。

  • upper_bound方法返回一个迭代器,指向地图中第一个具有大于提供的键的键的项目

  • equal_range方法返回pair对象中的下限和上限值

集合和多重集

集合的行为就像是地图,但键与值相同;例如,以下内容:

    set<string> people{ 
       "Washington","Adams", "Jefferson","Madison","Monroe",  
       "Adams", "Van Buren","Harrison","Tyler","Polk"}; 
    for (string s : people) cout << s << endl;

这将按字母顺序打印出个人,因为有两个名为Adams的项目,而set类将拒绝重复。当项目插入到集合中时,它将被排序,而在这种情况下,顺序是由比较两个string对象的词典顺序决定的。如果要允许重复,以便将十个人放入容器中,那么应该使用multiset

map一样,您不能更改容器中项目的键,因为键用于确定排序。对于set,键与值相同,因此这意味着您根本不能更改项目。如果意图是执行查找,那么最好使用排序的vectorset的内存分配开销比vector更大。潜在地,对set容器的查找将比对vector容器更快,如果搜索是顺序的,但如果使用binary_search调用(稍后在排序项目部分中解释),它可能比关联容器更快。

set类的接口是map类的受限版本,因此您可以在容器中insertemplace项目,将其分配给另一个容器中的值,并具有迭代器访问(beginend方法)。

由于没有明确的键,这意味着find方法寻找的是值,而不是键(类似的还有边界方法;例如equal_range)。没有at方法,也没有[]运算符。

无序容器

mapset类允许您快速查找对象,这是由这些类按排序顺序保存项目所实现的。如果您遍历项目(从beginend),那么您将按排序顺序获取这些项目。如果您想要在键值范围内选择对象,可以调用lower_boundupper_bound方法,以获取适当键范围的迭代器。这是这些关联容器的两个重要特性:查找和排序。在某些情况下,值的实际顺序并不重要,您想要的是高效的查找行为。在这种情况下,您可以使用mapset类的unordered_版本。由于顺序不重要,这些是使用哈希表实现的。

特定目的的容器

到目前为止描述的容器是灵活的,可以用于各种目的。标准库提供了具有特定目的的类,但由于它们是通过包装其他类实现的,因此它们被称为容器适配器。例如,deque对象可以通过将对象推入deque的后端(使用push_back)并使用front方法从队列的前端访问对象(并使用pop_front将其移除)来用作先进先出FIFO)队列。标准库实现了一个名为queue的容器适配器,它具有这种 FIFO 行为,并且基于deque类。

    queue<int> primes; 
    primes.push(1); 
    primes.push(2); 
    primes.push(3); 
    primes.push(5); 
    primes.push(7); 
    primes.push(11); 
    while (primes.size() > 0) 
    { 
        cout << primes.front() << ","; 
        primes.pop(); 
    } 
    cout << endl; // prints 1,2,3,5,7,11

您可以使用push方法将项目推入队列,并使用pop方法将其移除,并使用front方法访问下一个项目。可以通过此适配器包装的标准库容器必须实现push_backpop_frontfront方法。也就是说,项目被放入容器的一端,并从另一端访问(和移除)。

后进先出LIFO)容器将项目放入并从同一端访问(和移除)项目。同样,可以使用deque对象来实现这种行为,通过使用push_back推入项目,使用front访问项目,并使用pop_back方法删除它们。标准库提供了一个适配器类叫做stack来提供这种行为。它有一个名为push的方法将项目推入容器,一个名为pop的方法来移除项目,但是,奇怪的是,您使用top方法来访问下一个项目,尽管它是使用包装容器的back方法实现的。

适配器类priority_queue,尽管名字是这样的,但是它的使用方式类似于stack容器;也就是说,使用top方法来访问项目。容器确保当一个项目被推入时,队列的顶部始终是具有最高优先级的项目。谓词(默认为<)用于对队列中的项目进行排序。例如,我们可以有一个聚合类型,它具有任务的名称和您必须完成任务的优先级与其他任务相比:

    struct task 
    { 
    string name; 
    int priority; 
    task(const string& n, int p) : name(n), priority(p) {} 
    bool operator <(const task& rhs) const { 
        return this->priority < rhs.priority; 
        } 
    };

聚合类型很简单;它有两个数据成员,由构造函数初始化。为了能够对任务进行排序,我们需要能够比较两个任务对象。一个选项(前面提到过)是定义一个单独的谓词类。在这个例子中,我们使用默认的谓词,文档中说的是less<task>,它根据<运算符比较项目。为了能够使用默认的谓词,我们为task类定义了<运算符。现在我们可以将任务添加到priority_queue容器中:

    priority_queue<task> to_do; 
    to_do.push(task("tidy desk", 1)); 
    to_do.push(task("check in code", 10)); 
    to_do.push(task("write spec", 8)); 
    to_do.push(task("strategy meeting", 8)); 

    while (to_do.size() > 0) 
    { 
        cout << to_do.top().name << " " << to_do.top().priority << endl; 
        to_do.pop(); 
    }

这段代码的结果是:

    check in code 10
write spec 8
strategy meeting 8
tidy desk 1

队列根据priority数据项对任务进行了排序,toppop方法的组合调用按优先级顺序读取项目并将其从队列中移除。具有相同优先级的项目按照它们被推入的顺序放入队列。

使用迭代器

到目前为止,在本章中,我们已经指出容器通过迭代器访问项目。这意味着迭代器只是指针,这是有意为之的,因为迭代器的行为类似于指针。但是,它们通常是迭代器类的对象(请参阅<iterator>头文件)。所有迭代器都具有以下行为:

运算符 行为
* 访问当前位置的元素
++ 向前移动到下一个元素(通常您将使用前缀运算符)(只有在迭代器允许向前移动时才会出现)
-- 向后移动到上一个元素(通常您将使用前缀运算符)(只有在迭代器允许向后移动时才会出现)
==!= 比较两个迭代器是否处于相同位置
= 分配一个迭代器

与 C++指针不同,它假设数据在内存中是连续的,迭代器可以用于更复杂的数据结构,例如链表,其中项目可能不是连续的。无论底层存储机制如何,操作符++--都能正常工作。

<iterator>头文件声明了next全局函数,它将增加一个迭代器,以及advance函数,它将按指定数量的位置更改迭代器(向前或向后,取决于参数是否为负数以及迭代器允许的方向)。还有一个prev函数,用于将迭代器减少一个或多个位置。distance函数可用于确定两个迭代器之间有多少项。

所有容器都有一个begin方法,它返回第一个项目的迭代器,以及一个end方法,它返回最后一个项目之后的迭代器。这意味着您可以通过调用begin并递增迭代器直到它具有从end返回的值来遍历容器中的所有项目。迭代器上的*运算符可以访问容器中的元素,如果迭代器是可读写的(如果从 begin 方法返回的话),则意味着该项目可以被更改。容器还有cbegincend方法,它们将返回一个只读访问元素的常量迭代器:

    vector<int> primes { 1,2,3,5,7,11,13 }; 
    const auto it = primes.begin(); // const has no effect 
    *it = 42; 
    auto cit = primes.cbegin(); 
    *cit = 1;                       // will not compile

这里const没有影响,因为变量是auto,类型是从用于初始化变量的项目推断出来的。cbegin方法被定义为返回一个const迭代器,因此您不能更改它所引用的项目。

begincbegin方法返回正向迭代器,因此++运算符将迭代器向前移动。容器还可以支持反向迭代器,其中rbegin是容器中的最后一个项目(即end返回的位置之前的项目),rend是第一个项目之前的位置。(还有crbegincrend,它们返回const迭代器。)重要的是要意识到,反向迭代器的++运算符向移动,如下例所示:

    vector<int> primes { 1,2,3,5,7,11,13 }; 
    auto it = primes.rbegin(); 
    while (it != primes.rend()) 
    { 
        cout << *it++ << " "; 
    } 
    cout << endl; // prints 13,11,7,5,4,3,2,1

++运算符根据应用于的迭代器类型来递增迭代器。重要的是要注意,!=运算符在这里用于确定循环是否应该结束,因为!=运算符将在所有迭代器上定义。

在这里,使用auto关键字忽略了迭代器类型。实际上,所有容器都将为它们使用的所有迭代器类型定义typedef,因此在前面的情况下,我们可以使用以下内容:

    vector<int> primes { 1,2,3,5,7,11,13 }; 
    vector<int>::iterator it = primes.begin();

允许正向迭代的容器将具有iteratorconst_iteratortypedef,而允许反向迭代的容器将具有reverse_iteratorconst_reverse_iteratortypedef。为了完整起见,容器还将为返回指向元素的指针的方法定义pointerconst_pointertypedef,以及为返回元素引用的方法定义referenceconst_referencetypedef。这些类型定义使您能够编写通用代码,其中您不知道容器中的类型,但代码仍然能够声明正确类型的变量。

尽管它们看起来像指针,但迭代器通常由类实现。这些类型可能只允许单向迭代:正向迭代器只有++运算符,反向迭代器有-运算符,或者类型可以允许双向迭代(双向迭代器),因此它们实现了++--运算符。例如,listsetmultisetmapmultimap类上的迭代器是双向的。vectordequearraystring类具有允许随机访问的迭代器,因此这些迭代器类型具有与双向迭代器相同的行为,但也具有指针的算术,因此它们可以一次更改多个项目位置。

输入和输出迭代器

顾名思义,输入迭代器只能向前移动并且具有读取访问权限,输出迭代器只能向前移动但具有写入访问权限。这些迭代器没有随机访问权限,也不允许向后移动。例如,输出流可以与输出迭代器一起使用:你将解引用的迭代器分配给数据项,以便将该数据项写入流中。同样,输入流可以有一个输入迭代器,你解引用迭代器以访问流中的下一个项。这种行为意味着对于输出迭代器,解引用运算符(*)的唯一有效用法是在赋值的左侧。检查迭代器的值是否等于!=是没有意义的,你也不能检查通过输出迭代器分配值是否成功。

例如,transform函数接受三个迭代器和一个函数。前两个迭代器是输入迭代器,并指示要通过函数转换的项的范围。结果将放在一系列项中(与输入迭代器的范围大小相同),第一个由第三个迭代器指示,这是一个输出迭代器。一种方法是这样的:

    vector<int> data { 1,2,3,4,5 }; 
    vector<int> results; 
    results.resize(data.size()); 
    transform( 
       data.begin(), data.end(),  
       results.begin(), 
       [](int x){ return x*x; } );

这里的beginend方法返回data容器上的迭代器,这些迭代器可以安全地用作输入迭代器。results容器上的begin方法只能用作输出迭代器,只要容器有足够的分配项,这在这段代码中是成立的,因为它们已经被resize分配了。然后函数将通过将输入项传递给最后一个参数中给定的 lambda 函数(它只是返回值的平方)来转换每个输入项。重要的是要重新评估这里发生了什么;transform函数的第三个参数是一个输出迭代器,这意味着你应该期望函数通过这个迭代器写入值。

这段代码可以工作,但它需要额外的步骤来分配空间,并且你需要额外分配默认对象到容器中,只是为了覆盖它们。还要注意输出迭代器不一定要指向另一个容器。只要它指向可以写入的范围,它可以指向同一个容器:

    vector<int> vec{ 1,2,3,4,5 }; 
    vec.resize(vec.size() * 2); 
    transform(vec.begin(), vec.begin() + 5, 
       vec.begin() + 5, [](int i) { return i*i; });

vec容器被调整大小,以便为结果腾出空间。要转换的值的范围是从第一个项到第五个项(vec.begin() + 5是下一个项),写入转换值的位置是第六到第十个项。如果你打印出向量,你会得到{1,2,3,4,5,1,4,9,16,25}

另一种输出迭代器是插入器。back_inserter用于具有push_back的容器,front_inserter用于具有push_front的容器。顾名思义,插入器在容器上调用insert方法。例如,你可以这样使用back_inserter

    vector<int> data { 1,2,3,4,5 }; 
    vector<int> results; 
    transform( 
       data.begin(), data.end(),  
       back_inserter(results), 
       [](int x){ return x*x; } ); // 1,4,9,16,25

转换的结果被插入到results容器中,使用从back_inserter类创建的临时对象。使用back_inserter对象可以确保当transform函数通过迭代器写入时,该项被插入到包装容器中,使用push_back。请注意,结果容器应该与源容器不同。

如果你想要逆序的值,那么如果容器支持push_front(例如deque),那么你可以使用front_insertervector类没有push_front方法,但它有反向迭代器,所以你可以使用它们代替:

    vector<int> data { 1,2,3,4,5 }; 
    vector<int> results; 
    transform( 
 data.rbegin(), data.rend(), 
       back_inserter(results), 
       [](int x){ return x*x; } ); // 25,16,9,4,1

要颠倒结果的顺序,你只需要将begin改为rbegin,将end改为rend

流迭代器

这些是<iterators>中的适配器类,可以用来从输入流中读取项或将项写入输出流。例如,到目前为止,我们已经通过范围for循环使用迭代器来打印容器的内容:

    vector<int> data { 1,2,3,4,5 }; 
    for (int i : data) cout << i << " "; 
    cout << endl;

相反,你可以创建一个基于cout的输出流迭代器,这样int值将通过这个迭代器使用流运算符<<写入cout流。要打印出一个int值的容器,你只需将容器复制到输出迭代器:

    vector<int> data { 1,2,3,4,5 }; 
    ostream_iterator<int> my_out(cout, " "); 
    copy(data.cbegin(), data.cend(), my_out); 
    cout << endl;

ostream_iterator类的第一个参数是它将适配的输出流,可选的第二个参数是在每个项目之间使用的分隔符字符串。copy函数(在<algorithm>中)将复制由输入迭代器指示的范围中的项目,作为前两个参数传递,到作为最后一个参数传递的输出迭代器中。

类似地,还有一个istream_iterator类,它将包装一个输入流对象并提供一个输入迭代器。这个类将使用流的>>运算符来提取指定类型的对象,这些对象可以通过流迭代器读取。然而,从流中读取数据比写入更复杂,因为必须检测迭代器读取输入流时是否没有更多的数据(文件结束的情况)。

istream_iterator类有两个构造函数。一个构造函数有一个参数,即要读取的输入流,另一个构造函数,即默认构造函数,没有参数,用于创建一个流结束迭代器。流结束迭代器用于指示流中没有更多数据:

    vector<int> data; 
    copy( 
       istream_iterator<int>(cin), istream_iterator<int>(), 
       back_inserter(data)); 

    ostream_iterator<int> my_out(cout, " "); 
    copy(data.cbegin(), data.cend(), my_out); 
    cout << endl;

第一次调用copy提供了两个输入迭代器作为前两个参数,以及一个输出迭代器。该函数将数据从第一个迭代器复制到最后一个参数中的输出迭代器。由于最后一个参数是由back_inserter创建的,这意味着项目将插入到vector对象中。输入迭代器基于输入流(cin),因此copy函数将从控制台读取int值(每个值之间用空格分隔),直到没有更多可用的值(例如,如果按下CTRL + Z结束流,或者输入一个非数字项目)。由于可以使用迭代器给定的值范围初始化容器,因此可以使用istream_iterator作为构造函数参数:

    vector<int> data {  
       istream_iterator<int>(cin), istream_iterator<int>() };

这里使用初始化列表语法调用构造函数;如果使用括号,编译器将解释为函数的声明!

正如前面所指出的,istream_iterator将使用流的>>运算符从流中读取指定类型的对象,而这个运算符使用空格来分隔项目(因此它只会忽略所有空格)。如果你读取一个string对象的容器,那么你在控制台上输入的每个单词都将成为容器中的一个项目。string是一个字符的容器,也可以使用迭代器进行初始化,因此你可以尝试使用istream_iterator从控制台输入数据到一个string中:

    string data { 
            istream_iterator<char>(cin), istream_iterator<char>() };

在这种情况下,流是cin,但它也可以很容易地是一个指向文件的ifstream对象。问题在于cin对象将剥离掉空格,因此string对象将包含你输入的除了空格之外的所有内容,因此不会有空格和换行符。

这个问题是由istream_iterator使用流的>>运算符引起的,只能通过使用另一个类istreambuf_iterator来避免:

    string data { 
        istreambuf_iterator<char>(cin), istreambuf_iterator<char>() };

这个类从流中读取每个字符,并将每个字符复制到容器中,而不进行>>的处理。

使用 C 标准库的迭代器

C 标准库通常需要指向数据的指针。例如,当 C 函数需要一个字符串时,它将需要一个指向包含字符串的字符数组的const char*指针。C标准库已经被设计成允许你使用它的类与 C 标准库一起使用;事实上,C 标准库是 C标准库的一部分。对于string对象,解决方法很简单:当你需要一个const char*指针时,你只需在string对象上调用c_str方法。

存储数据在连续内存中的容器(arraystringdata)具有一个名为data的方法,该方法允许以 C 数组的形式访问容器的数据。此外,这些容器具有[]操作符访问其数据,因此您也可以将第一项的地址视为&container[0](其中container是容器对象),就像您对 C 数组一样。但是,如果容器为空,这个地址将是无效的,因此在使用之前,您应该调用empty方法。这些容器中的项目数量是从size方法返回的,因此对于任何需要指向 C 数组开头和大小的指针的 C 函数,您可以使用&container[0]size方法的值来调用它。

您可能会尝试通过调用其begin函数来获取具有连续内存的容器的开头,但这将返回一个迭代器(通常是一个对象)。因此,要获得指向第一个项目的 C 指针,您应该调用&*begin;也就是说,解引用从begin函数返回的迭代器以获取第一个项目,然后使用地址运算符获取其地址。坦率地说,&container[0]更简单,更易读。

如果容器不将其数据存储在连续内存中(例如dequelist),那么您可以通过将数据复制到临时向量中来获得 C 指针。

    list<int> data; 
    // do some calculations and fill the list 
    vector<int> temp(data.begin(), data.end()); 
    size_t size = temp.size(); // can pass size to a C function 
    int *p = &temp[0];         // can pass p to a C function

在这种情况下,我们选择使用list,并且该例程将操作data对象。稍后在例程中,这些值将被传递给 C 函数,因此list用于初始化vector对象,并且这些值是从vector中获取的。

算法

标准库在<algorithm>头文件中具有大量的通用函数集。通用意味着它们通过迭代器访问数据,而不知道迭代器指的是什么,这意味着您可以编写通用代码以适用于任何适当的容器。但是,如果您知道容器类型,并且该容器具有执行相同操作的成员方法,那么应该使用该成员。

项目的迭代

<algorithm>中的许多例程将接受范围并迭代执行某些操作。正如名称所示,fill函数将使用值填充容器。该函数需要两个迭代器来指定范围和一个将放置在容器每个位置的值:

    vector<int> vec; 
    vec.resize(5); 
    fill(vec.begin(), vec.end(), 42);

由于fill函数将用于范围,这意味着您必须传递迭代器到已经具有值的容器,这就是为什么此代码调用resize方法的原因。此代码将将值42放入容器的每个项目中,因此当它完成时,vector包含{42,42,42,42,42}。此函数的另一个版本称为fill_n,它通过单个迭代器到范围的开始和范围中的项目数来指定范围。

generate函数类似,但是,它不是单个值,而是一个函数,可以是函数、函数对象或 lambda 表达式。调用该函数以提供容器中的每个项目,因此它没有参数,并返回由迭代器访问的类型的对象:

    vector<int> vec(5); 
    generate(vec.begin(), vec.end(),  
        []() {static int i; return ++i; });

再次,您必须确保generate函数传递的是已经存在的范围,此代码通过将初始大小作为构造函数参数来实现这一点。在这个例子中,lambda 表达式具有一个static变量,每次调用时都会递增,因此这意味着在generate函数完成后,vector包含{1,2,3,4,5}。此函数的另一个版本称为generate_n,它通过单个迭代器到范围的开始和范围中的项目数来指定范围。

for_each函数将迭代由两个迭代器提供的范围,并对范围中的每个项目调用指定的函数。此函数必须具有与容器中项目相同类型的单个参数:

    vector<int> vec { 1,4,9,16,25 }; 
    for_each(vec.begin(), vec.end(),  
         [](int i) { cout << i << " "; }); 
    cout << endl;

for_each函数遍历迭代器指定的所有项目(在本例中是整个范围),解引用迭代器,并将项目传递给函数。此代码的效果是打印容器的内容。函数可以按值(在本例中)或按引用传递项目。如果通过引用传递项目,则函数可以更改项目:

    vector<int> vec { 1,2,3,4,5 }; 
    for_each(vec.begin(), vec.end(),  
         [](int& i) { i *= i; });

调用此代码后,vector中的项目将被替换为这些项目的平方。如果使用函数对象或 lambda 表达式,可以传递一个容器来捕获函数的结果;例如:

    vector<int> vec { 1,2,3,4,5 }; 
    vector<int> results; 
    for_each(vec.begin(), vec.end(),  
         &results { results.push_back(i*i); });

在这里,声明了一个容器来接受对 lambda 表达式的每次调用的结果,并通过捕获将变量通过引用传递给表达式。

回想一下第五章中的使用函数,方括号中包含在表达式外声明的捕获变量的名称。一旦捕获,这意味着表达式能够访问该对象。

在这个例子中,每次迭代的结果(i*i)都被推送到捕获的集合中,以便稍后存储结果。

transform函数有两种形式;它们都提供一个函数(指针、函数对象或 lambda 表达式),它们都通过迭代器传递容器中的项目的输入范围。在这方面,它们类似于for_eachtransform函数还允许您传递一个用于存储函数结果的容器的迭代器。该函数必须有一个与输入迭代器引用的类型相同的参数,并且必须返回由输出迭代器访问的类型。

transform的另一个版本使用一个函数来组合两个范围中的值,这意味着该函数必须有两个参数(将是两个迭代器中对应的项),并返回输出迭代器的类型。您只需要提供其中一个输入范围的所有项目的完整范围,因为假定另一个范围至少与之一样大,因此您只需要提供第二个范围的开始迭代器:

    vector<int> vec1 { 1,2,3,4,5 }; 
    vector<int> vec2 { 5,4,3,2,1 }; 
    vector<int> results; 
    transform(vec1.begin(), vec1.end(), vec2.begin(), 
       back_inserter(results), [](int i, int j) { return i*j; });

获取信息

一旦容器中有值,就可以调用函数来获取有关这些项的信息。count函数用于计算范围内具有指定值的项目数:

    vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
    auto number = count(planck.begin(), planck.end(), 6);

这段代码将返回值3,因为容器中有三个6的副本。函数的返回类型是容器的difference_typetypedef指定的类型,在这种情况下将是intcount_if函数的工作方式类似,但您传递一个谓词,该谓词接受一个参数(容器中的当前项目)并返回一个bool,指定是否正在计数的是该值。

count函数计算特定值的出现次数。如果要聚合所有值,可以使用<numeric>中的accumulate函数。这将遍历范围,访问每个项目,并保持所有项目的累积总和。总和将使用类型的+运算符进行,但也有一个版本,它接受一个二元函数(容器类型的两个参数并返回相同类型),指定当您将两个这样的类型相加时会发生什么。

all_ofany_ofnone_of函数传递一个具有与容器相同类型的单个参数的谓词;它们还给出了指示它们迭代的范围的迭代器,用谓词测试每个项目。all_of函数仅在所有项目的谓词为true时返回trueany_of函数在至少一个项目的谓词为true时返回truenone_of函数仅在所有项目的谓词为false时返回true

比较容器

如果您有两个数据容器,有各种方法可以比较它们。对于每种容器类型,都定义了 <<===!=>>= 运算符。==!= 运算符比较容器,既根据它们具有的项目数量,也根据这些项目的值。因此,如果项目具有不同数量的项目、不同的值或两者都有,则它们不相等。其他比较更喜欢值而不是项目数量:

    vector<int> v1 { 1,2,3,4 }; 
    vector<int> v2 { 1,2 }; 
    vector<int> v3 { 5,6,7 }; 
    cout << boolalpha; 
    cout << (v1 > v2) << endl; // true 
    cout << (v1 > v3) << endl; // false

在第一个比较中,两个向量具有相似的项目,但 v2 的项目较少,因此 v1 "大于" v2。在第二种情况下,v3 的值大于 v1,但数量较少,因此 v3 大于 v1

您还可以使用 equal 函数比较范围。它传递了两个范围(假定它们的大小相同,因此只需要第二个范围的起始迭代器),并使用 == 运算符或用户提供的谓词比较两个范围中的对应项。只有当所有这样的比较都为 true 时,函数才会返回 true。类似地,mismatch 函数比较两个范围中的对应项。但是,此函数返回一个 pair 对象,其中包含两个范围中的迭代器,指向第一个不同的项。您还可以提供一个比较函数。is_permutation 类似于它比较两个范围中的值,但是如果两个范围具有相同的值但不一定是相同顺序,则返回 true

更改项目

reverse 函数作用于容器中的范围,并颠倒项目的顺序;这意味着迭代器必须是可写的。copycopy_n 函数以向前方向将一个范围中的每个项目复制到另一个范围中;对于 copy,输入范围由两个输入迭代器给出,对于 copy_n,范围是一个输入迭代器和项目计数。copy_backward 函数将从范围的末尾开始复制项目,以便输出范围中的项目顺序与原始项目相同。这意味着输出迭代器将指示要复制到的范围的 end。您还可以仅在它们满足谓词指定的某些条件时才复制项目。

  • reverse_copy 函数将以与输入范围相反的顺序创建副本;实际上,该函数向后迭代原始范围,并将项目向前复制到输出范围。

  • 尽管名称不同,movemove_backward 函数在语义上等同于 copycopy_backward 函数。因此,在接下来的操作中,原始容器在操作后将具有相同的值:

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        vector<int> result(4);          // we want 4 items 
        auto it1 = planck.begin();      // get the first position 
        it1 += 2;                       // move forward 2 places 
        auto it2 = it1 + 4;             // move 4 items 
        move(it1, it2, result.begin()); // {2,6,0,7}
  • 此代码将从第一个容器中复制四个项目到第二个容器,从第三个位置的项目开始。

  • remove_copyremove_copy_if 函数遍历源范围,并复制除具有指定值的项目之外的项目。

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        vector<int> result; 
        remove_copy(planck.begin(), planck.end(),  
            back_inserter(result), 6);
  • 在这里,planck 对象与以前一样,result 对象将包含 {2,0,7,0,0,4,0}remove_copy_if 函数的行为类似,但是给定的是谓词而不是实际值。

  • removeremove_if 函数并不完全按照它们的名称所暗示的那样。这些函数作用于单个范围,并寻找特定值(remove),或将每个项目传递给将指示是否应删除该项目的谓词(remove_if)。当删除项目时,容器中后面的项目将向前移动,但容器的大小保持不变,这意味着末尾的项目保持不变。remove 函数的行为如此是因为它们只知道通过迭代器读取和写入项目(这对所有容器都是通用的)。要擦除项目,函数将需要访问容器的 erase 方法,而 remove 函数只能访问迭代器。

  • 如果您想要删除末尾的项目,那么您必须相应地调整容器的大小。通常,这意味着在容器上调用适当的erase方法,这是因为remove方法返回一个指向新末尾位置的迭代器:

        vector<int> planck { 6,6,2,6,0,7,0,0,4,0 }; 
        auto new_end = remove(planck.begin(), planck.end(), 6); 
                                             // {2,0,7,0,0,4,0,0,4,0} 
        planck.erase(new_end, planck.end()); // {2,0,7,0,0,4,0}
  • replacereplace_if函数遍历单个范围,如果值是指定的值(replace)或从谓词返回truereplace_if),则用指定的新值替换该项目。还有两个函数,replace_copyreplace_copy_if,它们不会影响原始容器,而是将更改复制到另一个范围(类似于remove_copyremove_copy_if函数)。

  • rotate函数将范围视为末尾连接到开头,因此您可以将项目向前移动,以便当项目从末尾掉下时,它会被放在第一个位置。如果您想将每个项目向前移动四个位置,可以这样做:

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        auto it = planck.begin(); 
        it += 4; 
        rotate(planck.begin(), it, planck.end());
  • 这种旋转的结果是{0,7,0,0,4,0,6,6,2,6}rotate_copy函数也是做同样的事情,但是,它不会影响原始容器,而是将项目复制到另一个容器中。

  • unique函数作用于范围,并且“删除”(以前解释的方式)与相邻项目重复的项目,并且您可以为函数提供一个谓词来测试两个项目是否相同。此函数仅检查相邻项目,因此容器中稍后的重复项将保留。如果要删除所有重复项,则应首先对容器进行排序,以便相似的项目相邻。

  • unique_copy函数将项目从一个范围复制到另一个范围,仅当它们是唯一的时才这样做,因此删除重复项的一种方法是在临时容器上使用此函数,然后将原始容器分配给临时容器:

        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        vector<int> temp; 
        unique_copy(planck.begin(), planck.end(), back_inserter(temp)); 
        planck.assign(temp.begin(), temp.end());
  • 在这段代码之后,planck容器将为{6,2,6,0,7,0,4,0}

  • 最后,iter_swap将交换两个迭代器指示的项目,而swap_ranges函数将一个范围中的项目交换到另一个范围中(第二个范围由一个迭代器指示,并且假定它指的是与第一个范围大小相同的范围)。

查找项目

标准库有各种函数来搜索项目:

  • min_element函数将返回范围中最小项目的迭代器,而max_element函数将返回最大项目的迭代器。这些函数接受要检查的项目范围的迭代器和一个从比较两个项目返回bool的谓词。如果您不提供谓词,将使用该类型的<运算符。
        vector<int> planck{ 6,6,2,6,0,7,0,0,4,0 }; 
        auto imin = min_element(planck.begin(), planck.end()); 
        auto imax = max_element(planck.begin(), planck.end()); 
        cout << "values between " << *imin << " and "<< *imax << endl;
  • iminimax值是迭代器,这就是为什么它们被解引用以获取值。如果您想一次获取最小元素和最大元素,可以调用minmax_element,它将返回一个pair对象,其中包含指向这些项目的迭代器。顾名思义,adjacent_find函数将返回具有相同值的前两个项目的位置(您可以提供谓词来确定相同值的含义)。这使您可以搜索重复项并获取这些重复项的位置。
        vector<int> vec{0,1,2,3,4,4,5,6,7,7,7,8,9}; 
        vector<int>::iterator it = vec.begin(); 

        do 
        { 
            it = adjacent_find(it, vec.end()); 
            if (it != vec.end()) 
            {  
                cout << "duplicate " << *it << endl; 
                ++it; 
            } 
        } while (it != vec.end());
  • 这段代码中有一系列数字,其中有一些相邻重复的数字。在这种情况下,有三个相邻的重复:4后面跟着4,以及序列7,7,77后面跟着7,以及7后面跟着7do循环重复调用adjacent_find,直到它返回end迭代器,表示已经搜索了所有项目。当找到重复对时,代码会打印出该值,然后增加下一次搜索的起始位置。

  • find函数在容器中搜索单个值,并返回指向该项的迭代器,如果找不到该值,则返回end迭代器。find_if函数传递一个谓词,并返回找到满足谓词的第一项的迭代器;类似地,find_if_not函数找到不满足谓词的第一项。

  • 有几个函数给定两个范围,一个是要搜索的范围,另一个是要查找的值。不同的函数将查找搜索条件中的一个项目,或者将查找所有这些项目。这些函数使用容器持有的类型的==运算符或谓词。

  • find_first_of函数返回在搜索列表中找到的第一个项目的位置。search函数查找特定序列,并返回整个序列的第一个位置,而find_end函数返回整个搜索序列的最后位置。最后,search_n函数在指定容器范围内查找重复多次的值(给定值和重复次数的值)的序列。

排序项目

序列容器可以排序,一旦完成排序,您可以使用方法搜索项目,合并容器或获取容器之间的差异。sort函数将根据提供的<运算符或谓词对范围内的项目进行排序。如果范围内有相等的项目,则这些项目在排序后的顺序不能保证;如果这个顺序很重要,您应该调用stable_sort函数。如果要保留输入范围并将排序后的项目复制到另一个范围中,可以使用令人困惑的partial_sort_copy函数。这不是部分排序。此函数传递输入范围的迭代器和输出范围的迭代器,因此您必须确保输出范围具有合适的容量。

您可以通过调用is_sorted函数来检查范围是否已排序,如果找到不按排序顺序排列的项目,则会遍历所有项目并返回false,在这种情况下,您可以通过调用is_sorted_until函数找到第一个不按顺序排列的项目。

正如其名称所示,partial_sort函数不会将每个项目放置在与其他每个项目的确切顺序中。相反,它将创建两个组或分区,第一个分区将包含最小的项目(不一定按任何顺序),而另一个分区将包含最大的项目。您可以确保最小的项目在第一个分区中。要调用此函数,您需要传递三个迭代器,其中两个是要排序的范围,第三个是介于其他两个之间的位置,指示最小值之前的边界。

    vector<int> vec{45,23,67,6,29,44,90,3,64,18}; 
    auto middle = vec.begin() + 5; 
    partial_sort(vec.begin(), middle, vec.end()); 
    cout << "smallest items" << endl; 
    for_each(vec.begin(), middle, [](int i) {cout << i << " "; }); 
    cout << endl; // 3 6 18 23 29 
    cout << "biggest items" << endl; 
    for_each(middle, vec.end(), [](int i) {cout << i << " "; }); 
    cout << endl; // 67 90 45 64 44

在这个例子中有一个包含十个项目的向量,所以我们将middle迭代器定义为距离开头五个项目(这只是一个选择,根据您想要获得多少项目,它可能是其他值)。在这个例子中,您可以看到五个最小的项目已经排序到了前半部分,而后半部分有最大的项目。

奇怪命名的nth_element函数的作用类似于partial_sort。您提供一个迭代器给第n个元素,函数确保范围内的前n个项目是最小的。nth_element函数比partial_sort更快,尽管您可以确保第n个元素之前的项目小于或等于第n个元素,但在分区内部的排序顺序没有其他保证。

partial_sortnth_element函数是分区排序函数的版本。partition函数是更通用的版本。您可以将此函数传递给一个范围和一个确定项目将被放置在两个分区中的谓词。满足谓词的项目将放在范围的第一个分区中,其他项目将放在第一个分区后面的范围中。第二个分区的第一个项目称为分区点,并且从partition函数返回,但是稍后可以通过将分区范围和谓词传递给partition_point函数来计算它。partition_copy函数也将分区值,但它将保持原始范围不变,并将值放入已经分配的范围中。这些分区函数不保证等效项目的顺序,如果这个顺序很重要,那么应该调用stable_partitian函数。最后,可以通过调用is_partitioned函数来确定容器是否已分区。

shuffle函数将容器中的项目重新排列成随机顺序。此函数需要来自<random>库的均匀随机数生成器。例如,以下将使用十个整数填充容器,然后以随机顺序放置它们:

    vector<int> vec; 
    for (int i = 0; i < 10; ++i) vec.push_back(i); 
    random_device rd; 
    shuffle(vec.begin(), vec.end(), rd);

堆是一个部分排序的序列,其中第一个项目始终是最大的,项目在堆中的添加和删除都是以对数时间进行的。堆是基于序列容器的,但奇怪的是,标准库没有提供适配器类,而是需要在现有容器上使用函数调用。要从现有容器创建堆,您需要将范围迭代器传递给make_heap函数,该函数将对容器进行堆排序。然后可以使用容器的push_back方法向容器添加新项目,但每次这样做时,都必须调用push_heap来重新排序堆。类似地,要从堆中获取项目,可以在容器上调用front方法,然后通过调用pop_heap函数来删除项目,该函数确保堆保持有序。可以通过调用is_heap来测试容器是否排列为堆,如果容器没有完全排列为堆,则可以通过调用is_heap_until来获取不满足堆条件的第一个项目的迭代器。最后,可以使用sort_heap将堆排序为排序序列。

一旦对容器进行了排序,就可以调用函数来获取有关序列的信息。lower_boundupper_bound方法已经在容器中进行了描述,并且这些函数的行为方式相同:lower_bound返回第一个具有大于或等于提供的值的位置,upper_bound返回下一个大于提供的值的位置。includes函数测试一个排序范围是否包含第二个排序范围中的项目。

set_开头的函数将两个排序序列合并为第三个容器。set_difference函数将复制第一个序列中不在第二个序列中的项目。这不是对称的操作,因为它不包括在第二个序列中但不在第一个序列中的项目。如果需要对称差异,则应调用set_symmetric_difference函数。set_intersection将复制两个序列中都存在的项目。set_union函数将合并两个序列。还有另一个函数可以合并两个序列,即merge函数。这两个函数之间的区别在于,对于set_union函数,如果一个项目在两个序列中都存在,结果容器中只会放入一个副本,而对于merge函数,结果容器中会放入两个副本。

如果一个范围是排序的,那么你可以调用equal_range函数来获取与传递给函数或谓词等价的元素的范围。这个函数返回一对迭代器,表示容器中值的范围。

需要排序容器的最后一个方法是binary_search。这个函数用于测试值是否在容器中。函数传递表示要测试的范围和一个值的迭代器,并且如果范围中有一个等于该值的项目,则返回true(你可以提供一个谓词来执行这个相等测试)。

使用数值库

标准库有几个类库来执行数值操作。在本节中,我们将涵盖两个:编译时算术,使用<ratio>,和复数,使用<complex>

编译时算术

分数是一个问题,因为有些分数没有足够的有效数字来准确表示它们,这会导致在进一步进行算术运算时失去精度。此外,计算机是二进制的,仅仅将十进制小数部分转换为二进制就会失去精度。<ratio>库提供了允许你将分数表示为整数比率的对象,并将分数计算作为比率进行的类。只有在进行了所有分数算术之后,你才会将数字转换为十进制,这意味着最小化了精度损失的可能性。<ratio>库中的类执行的计算是在编译时进行的,因此编译器会捕捉到诸如除以零和溢出等错误。

使用这个库很简单;你使用ratio类,并将分子和分母作为模板参数提供。分子和分母将被因式分解,并且你可以通过对象的numden成员访问这些值:

    ratio<15, 20> ratio; 
    cout << ratio.num << "/" << ratio.den << endl;

这将打印出3/4

分数算术是使用模板进行的(实际上,这些是ratio模板的特化)。乍一看可能有点奇怪,但你很快就会习惯的!

    ratio_add<ratio<27, 11>, ratio<5, 17>> ratio; 
    cout << ratio.num << "/" << ratio.den << endl;

这将打印出514/187(你可能需要拿些纸来进行分数计算以确认这一点)。数据成员实际上是static成员,因此创建变量没有太大意义。此外,因为算术是使用类型而不是变量进行的,最好通过这些类型访问成员:

    typedef ratio_add<ratio<27, 11>, ratio<5, 17>> sum; 
    cout << sum::num << "/" << sum::den << endl;

现在你可以将和类型作为任何你可以执行的其他操作的参数。四个二进制算术运算是通过ratio_addratio_subtractratio_multiplyratio_divide进行的。比较是通过ratio_equalratio_not_equalratio_greaterratio_greater_equalratio_lessratio_less_equal进行的。

    bool result = ratio_greater<sum, ratio<25, 19> >::value; 
    cout << boolalpha << result << endl;

这个操作测试之前进行的计算(514/187)是否大于分数25/19(是的)。编译器会捕捉到除以零和溢出的错误,因此以下内容将不会编译:

    typedef ratio<1, 0> invalid; 
    cout << invalid::num << "/" << invalid::den << endl;

然而,重要的是要指出,当访问分母时,编译器会在第二行发出错误。还有 SI 前缀的比率的 typedef。这意味着你可以在纳米中进行计算,当你需要以米为单位呈现数据时,可以使用nano类型来获取比率:

    double radius_nm = 10.0; 
    double volume_nm = pow(radius_nm, 3) * 3.1415 * 4.0 / 3.0; 
    cout << "for " << radius_nm << "nm " 
        "the volume is " << volume_nm << "nm3" << endl; 
    double factor = ((double)nano::num / nano::den); 
    double vol_factor = pow(factor, 3); 
    cout << "for " << radius_nm * factor << "m " 
        "the volume is " << volume_nm * vol_factor << "m3" << endl;

在这里,我们正在以纳米nm)为单位对球体进行计算。球体的半径为 10 纳米,所以第一次计算得到体积为 4188.67 立方纳米。第二次计算将纳米转换为米;因子是从nano比率中确定的(注意对于体积,因子是立方的)。你可以定义一个类来进行这样的转换:

    template<typename units> 
    class dist_units 
    { 
        double data; 
        public: 
            dist_units(double d) : data(d) {} 

        template <class other> 
        dist_units(const dist_units<other>& len) : data(len.value() *  
         ratio_divide<units, other>::type::den / 
         ratio_divide<units, other>::type::num) {} 

        double value() const { return data; } 
    };

该类是为特定类型的单位定义的,将通过ratio模板的实例化来表示。该类有一个构造函数用于初始化该单位的值,还有一个用于从其他单位转换的构造函数,它只是将当前单位除以其他类型的单位。这个类可以像这样使用:

    dist_units<kilo> earth_diameter_km(12742); 
    cout << earth_diameter_km.value() << "km" << endl; 
    dist_units<ratio<1>> in_meters(earth_diameter_km); 
    cout << in_meters.value()<< "m" << endl; 
    dist_units<ratio<1609344, 1000>> in_miles(earth_diameter_km); 
    cout << in_miles.value()<< "miles" << endl;

第一个变量基于kilo,因此单位是千米。为了将其转换为米,第二个变量类型基于ratio<1>,与ratio<1,1>相同。结果是earth_diameter_km中的值在放入in_meters时乘以 1000。将其转换为英里则更为复杂。一英里等于 1609.344 米。用于in_miles变量的比率是 1609344/1000 或 1609.344。我们正在用earth_diameter_km初始化变量,那么这个值不是乘以 1000 太大了吗?不,原因是earth_diameter_km的类型是dist_units<kilo>,因此千米和英里之间的转换将包括 1000 这个因素。

复数

复数不仅在数学上有兴趣,它们在工程和科学中也至关重要,因此complex类型是任何类型库的重要组成部分。复数由两部分组成--实部和虚部。顾名思义,虚数不是实数,也不能被视为实数。

在数学中,复数通常被表示为二维空间中的坐标。如果一个实数可以被认为是 x 轴上无限多个点中的一个,那么一个虚数可以被认为是 y 轴上无限多个点中的一个。这两者之间唯一的交点是原点,由于零就是零,它既可以是零实数也可以是零虚数。复数既有实部又有虚部,因此可以将其视为笛卡尔坐标系中的一个点。事实上,另一种可视化复数的方式是将其视为极坐标,其中该点被表示为指定长度的矢量,以指定的角度与 x 轴上的位置(正实数轴)相对应。

complex类是基于浮点类型的,并且有floatdoublelong double的特化版本。该类很简单;它有一个构造函数,带有两个参数,用于表示数字的实部和虚部,并且定义了赋值、比较、+-/*的运算符(成员方法和全局函数),作用于实部和虚部。

对于复数来说,像+这样的操作很简单:只需将实部相加,虚部相加,这两个和就是结果的实部和虚部。然而,乘法和除法则稍微复杂。在乘法中,你得到一个二次方程:两个实部相乘,两个虚部相乘,第一个的实部值与第二个的虚部值相乘,以及第一个的虚部值与第二个的实部值相乘。复杂之处在于,两个虚数相乘相当于两个等效实数相乘再乘以-1。此外,实数和虚数相乘会得到一个大小等于两个等效实数相乘的虚数。

复数还有一些函数可以对复数执行三角函数运算:sincostansinhcoshtanh;以及logexplog10powsqrt等基本数学运算。您还可以调用函数来创建复数并获取有关它们的信息。因此,polar函数将使用两个浮点数表示矢量长度和角度的极坐标。如果您有一个complex数对象,可以通过调用abs(获取长度)和arg(获取角度)来获取极坐标。

    complex<double> a(1.0, 1.0); 
    complex<double> b(-0.5, 0.5); 
    complex<double> c = a + b; 
    cout << a << " + " << b << " = " << c << endl; 
    complex<double> d = polar(1.41421, -3.14152 / 4); 
    cout << d << endl;

首先要指出的是,对于complex数,已经定义了ostream插入运算符,因此可以将它们插入到cout流对象中。此代码的输出如下:

    (1,1) + (-0.5,0.5) = (0.5,1.5)
(1.00002,-0.999979)

第二行显示了仅使用五位小数来表示 2 的平方根和-1/4π的限制,实际上这个数字是复数(1,-1)

使用标准库

在这个例子中,我们将开发一个简单的逗号分隔值CSV)文件的解析器。我们将遵循的规则如下:

  • 每条记录将占据一行,换行符表示一个新记录

  • 记录中的字段由逗号分隔,除非它们在引用的字符串内部

  • 字符串可以使用单引号(')或双引号(")进行引用,此时它们可以包含逗号作为字符串的一部分

  • 立即重复的引号(''"")是一个字面值,是字符串的一部分,而不是字符串的分隔符

  • 如果一个字符串被引用,那么字符串外部的空格将被忽略

这是一个非常基本的实现,省略了引用字符串可以包含换行符的通常要求。

在这个例子中,大部分的操作将使用string对象作为单个字符的容器。

首先在本书的文件夹中创建一个名为Chapter_08的章节文件夹。在该文件夹中,创建一个名为csv_parser.cpp的文件。由于应用程序将使用控制台输出和文件输入,因此在文件顶部添加以下行:

    #include <iostream> 
    #include <fstream> 

    using namespace std;

应用程序还将接受一个命令行参数,即要解析的 CSV 文件,因此在文件底部添加以下代码:

    void usage() 
    { 
        cout << "usage: csv_parser file" << endl; 
        cout << "where file is the path to a csv file" << endl; 
    } 

    int main(int argc, const char* argv[]) 
    { 
        if (argc <= 1) 
        { 
            usage(); 
            return 1; 
        } 
        return 0; 
    }

应用程序将逐行读取文件到vectorstring对象中,因此将<vector>添加到包含文件列表中。为了使编码更容易,定义如下内容在usage函数之上:

    using namespace std; 
    using vec_str = vector<string>;

main函数将逐行读取文件,最简单的方法是使用getline函数,因此将<string>头文件添加到包含文件列表中。在main函数的末尾添加以下行:

    ifstream stm; 
    stm.open(argv[1], ios_base::in); 
    if (!stm.is_open()) 
    { 
        usage(); 
        cout << "cannot open " << argv[1] << endl; 
        return 1; 
    } 

    vec_str lines; 
    for (string line; getline(stm, line); ) 
    { 
        if (line.empty()) continue; 
        lines.push_back(move(line)); 
    } 
    stm.close();

前几行使用ifstream类打开文件。如果找不到文件,则打开文件的操作失败,并通过调用is_open进行测试。接下来声明了一个vectorstring对象,并用从文件中读取的行填充。getline函数有两个参数:第一个是打开的文件流对象,第二个是包含字符数据的字符串。此函数返回流对象,该流对象具有bool转换运算符,因此for语句将循环,直到此流对象指示它无法再读取更多数据为止。当流到达文件末尾时,将设置内部的文件结束标志,这将导致bool转换运算符返回false值。

如果getline函数读取到空行,则无法解析string,因此对此进行了测试,并且这样的空行不会被存储。每个合法的行都被推入vector中,但由于这个string变量在此操作后将不再使用,因此我们可以使用移动语义,因此通过调用move函数来明确表示这一点。

现在这段代码将编译并运行(尽管不会产生任何输出)。您可以将其用于任何符合先前给定标准的 CSV 文件,但作为测试文件,我们使用了以下文件:

    George Washington,1789,1797 
    "John Adams, Federalist",1797,1801 
    "Thomas Jefferson, Democratic Republican",1801,1809 
    "James Madison, Democratic Republican",1809,1817 
    "James Monroe, Democratic Republican",1817,1825 
    "John Quincy Adams, Democratic Republican",1825,1829 
    "Andrew Jackson, Democratic",1829,1837 
    "Martin Van Buren, Democratic",1837,1841 
    "William Henry Harrison, Whig",1841,1841 
    "John Tyler, Whig",1841,1841 
    John Tyler,1841,1845

这些是 1845 年之前的美国总统;第一个字符串是总统的姓名和他们的从属关系,但是当总统没有从属关系时,它会被省略(华盛顿和泰勒)。然后是他们的任期开始和结束年份。

接下来,我们想解析向量中的数据,并根据先前给定的规则(用逗号分隔的字段,但尊重引号)将项目拆分为单独的字段。为此,我们将每一行表示为字段的list,每个字段都是string。在文件顶部添加<list>的包含。在文件顶部进行using声明时,添加以下内容:

    using namespace std; 
    using vec_str = vector<string>; 
    using list_str = list<string>;using vec_list = vector<list_str>;

现在,在main函数的底部,添加:

    vec_list parsed; 
    for (string& line : lines) 
    { 
        parsed.push_back(parse_line(line)); 
    }

第一行创建list对象的vectorfor循环遍历每一行,调用名为parse_line的函数,解析字符串并返回string对象的list。函数的返回值将是一个临时对象,因此是一个右值,这意味着将调用具有移动语义的push_back版本。

在使用函数之前,添加parse_line函数的开始:

    list_str parse_line(const string& line) 
    { 
        list_str data; 
        string::const_iterator it = line.begin(); 

        return data; 
    }

该函数将把字符串视为字符的容器,因此将通过const_iterator迭代line参数。解析将在do循环中进行,因此添加以下内容:

    list_str data; 
    string::const_iterator it = line.begin(); 
    string item; bool bQuote = false; bool bDQuote = false; do{++it; } while (it != line.end()); data.push_back(move(item)); 
    return data;

布尔变量将在下一刻被解释。do循环递增迭代器,当达到end值时,循环结束。item变量将保存解析的数据(此时为空),最后一行将值放入list;这样,在函数结束之前,任何未保存的数据都将存储在list中。由于item变量即将被销毁,对move的调用确保其内容被移入list而不是被复制。如果没有这个调用,将在将item放入list时调用字符串复制构造函数。

接下来,您需要对数据进行解析。为此,添加一个开关来测试三种情况:逗号(表示字段的结束),引号或双引号表示引号字符串。想法是逐个字符读取每个字段并构建其值,使用item变量。

    do 
    { 
        switch (*it) { case ''': break; case '"': break; case ',': break; default: item.push_back(*it); }; 
        ++it; 
    } while (it != line.end());

默认操作很简单:它将字符复制到临时字符串中。如果字符是单引号,我们有两个选择。要么引号在双引号引用的字符串中,在这种情况下,我们希望将引号存储在item中,要么引号是分隔符,在这种情况下,我们通过设置bQuote值来存储它是开放引号还是关闭引号。对于单引号的情况,添加以下内容:

    case ''': 
    if (bDQuote) item.push_back(*it); else { bQuote = !bQuote; if (bQuote) item.clear(); } 
    break;

这很简单。如果这是在双引号字符串中(bDQuote已设置),那么我们存储引号。如果不是,那么我们翻转bQuote bool,以便如果这是第一个引号,我们注册字符串被引用,否则我们注册它是字符串的结尾。如果我们处于引号字符串的开头,我们清除item变量以忽略前一个逗号(如果有的话)和引号之间的任何空格。但是,此代码没有考虑连续使用两个引号的情况,这意味着引号是字符串的一部分。更改代码以检查此情况:

    if (bDQuote) item.push_back(*it); 
    else 
    { 
        if ((it + 1) != line.end() && *(it + 1) == ''') { item.push_back(*it); ++it; } else 
        { 
            bQuote = !bQuote; 
            if (bQuote) item.clear(); 
        } 
    }

if语句检查我们是否递增迭代器,以确保我们没有到达行的末尾(在这种情况下,短路将在此处启动,并且不会评估表达式的其余部分)。我们可以测试下一个项目,然后窥视下一个项目,看看它是否是单引号;如果是,那么我们将其添加到item变量中,并递增迭代器,以便在循环中消耗两个引号。

双引号的代码类似,但切换布尔变量并测试双引号:

    case '"': 
    if (bQuote) item.push_back(*it); else { if ((it + 1) != line.end() && *(it + 1) == '"') { item.push_back(*it); ++it; } else { bDQuote = !bDQuote; if (bDQuote) item.clear(); } } 
    break;

最后,我们需要代码来测试逗号。再次,我们有两种情况:要么这是引号字符串中的逗号,在这种情况下,我们需要存储字符,要么这是字段的结尾,在这种情况下,我们需要完成此字段的解析。代码非常简单:

    case ',': 
    if (bQuote || bDQuote)  item.push_back(*it); else                    data.push_back(move(item)); 
    break;

if语句用于检查我们是否在引号字符串中(在这种情况下,bQuotebDQuote将为 true),如果是,则存储字符。如果这是字段的结尾,我们将string推入list,但我们使用move,这样变量数据就会被移动,而string对象则处于未初始化状态。

这段代码将编译并运行。然而,仍然没有输出,所以在解决这个问题之前,回顾一下你写的代码。在main函数的末尾,你将会有一个vector,其中每个项目都有一个代表 CSV 文件中每一行的list对象,而list中的每个项目都是一个字段。你现在已经解析了文件,并可以相应地使用这些数据。为了能够看到数据已经被解析,将以下行添加到main函数的底部:

    int count = 0; 
    for (list_str row : parsed) 
    { 
        cout << ++count << "> "; 
        for (string field : row) 
        { 
            cout << field << " "; 
        } 
        cout << endl; 
    }

现在你可以编译这段代码(使用/EHsc开关)并运行应用程序,传递一个 CSV 文件的名称。

摘要

在本章中,你已经看到了 C++标准库中的一些主要类,并深入研究了容器和迭代器类。其中一个这样的容器是string类;这是一个如此重要的类,以至于它将在下一章中更深入地介绍。

第九章:使用字符串

在某个时候,您的应用程序将需要与人们交流,这意味着使用文本;例如输出文本,以文本形式接收数据,然后将该数据转换为适当的类型。C++标准库具有丰富的类集合,用于操作字符串,字符串和数字之间的转换,并获取特定语言和文化的本地化字符串值。

使用字符串类作为容器

C++字符串基于basic_string模板类。该类是一个容器,因此它使用迭代器访问和方法来获取信息,并且具有包含有关其保存的字符类型的信息的模板参数。有不同的特定字符类型的typedef

    typedef basic_string<char,
       char_traits<char>, allocator<char> > string; 
    typedef basic_string<wchar_t,
       char_traits<wchar_t>, allocator<wchar_t> > wstring; 
    typedef basic_string<char16_t,
       char_traits<char16_t>, allocator<char16_t> > u16string; 
    typedef basic_string<char32_t,
       char_traits<char32_t>, allocator<char32_t> > u32string;

string类基于charwstring基于wchar_t宽字符,16stringu32string类分别基于 16 位和 32 位字符。在本章的其余部分,我们将集中讨论string类,但同样适用于其他类。

比较,复制和访问字符串中的字符将需要不同大小字符的不同代码,而特性模板参数提供了实现。对于string,这是char_traits类。例如,当此类复制字符时,它将将此操作委托给char_traits类及其copy方法。特性类也被流类使用,因此它们还定义了适合文件流的文件结束值。

字符串本质上是一个零个或多个字符的数组,当需要时分配内存,并在string对象被销毁时释放它。在某些方面,它与vector<char>对象非常相似。作为容器,string类通过beginend方法提供迭代器访问:

    string s = "hellon"; 
    copy(s.begin(), s.end(), ostream_iterator<char>(cout));

在这里,调用beginend方法以从string中的项目获取迭代器,然后将它们传递给<algorithm>中的copy函数,以通过ostream_iterator临时对象将每个字符复制到控制台。在这方面,string对象类似于vector,因此我们使用先前定义的s对象:

vector<char> v(s.begin(), s.end()); 
copy(v.begin(), v.end(), ostream_iterator<char>(cout));

使用string对象上的beginend方法填充vector对象,然后使用copy函数将这些字符以与之前相同的方式打印到控制台。

获取有关字符串的信息

max_size方法将返回计算机架构上指定字符类型的字符串的最大大小,这可能非常大。例如,在具有 2GB 内存的 64 位 Windows 计算机上,string对象的max_size将返回 40 亿个字符,而wstring对象的方法将返回 20 亿个字符。这显然超过了机器上的内存!其他大小方法返回更有意义的值。length方法返回与size方法相同的值,即字符串中有多少项(字符)。capacity方法指示已分配多少内存用于字符串,以字符数表示。

您可以通过调用其compare方法将一个string与另一个进行比较。这将返回一个int而不是bool(但请注意,int可以被静默转换为bool),其中返回值为0表示两个字符串相同。如果它们不相同,此方法将返回负值,如果参数字符串大于操作数字符串,则返回负值,如果参数小于操作数字符串,则返回正值。在这方面,大于小于将按字母顺序测试字符串的排序。此外,还为<<===>=>定义了全局运算符,以比较字符串对象。

string对象可以通过c_str方法像 C 字符串一样使用。返回的指针是const;您应该注意,如果更改了string对象,指针可能会失效,因此不应存储此指针。您不应该使用&str[0]来获取 C++字符串str的 C 字符串指针,因为字符串类使用的内部缓冲区不能保证是NUL终止的。c_str方法用于返回一个可以用作 C 字符串的指针,因此是NUL终止的。

如果要将数据从 C++字符串复制到 C 缓冲区,可以调用copy方法。您将目标指针和要复制的字符数作为参数传递(以及可选的偏移量),该方法将尝试将最多指定数量的字符复制到目标缓冲区:但不包括空终止字符。此方法假定目标缓冲区足够大,可以容纳复制的字符(您应该采取措施来确保这一点)。如果要传递缓冲区的大小,以便该方法为您执行此检查,请调用_Copy_s方法。

修改字符串

字符串类具有标准的容器访问方法,因此您可以通过引用(读写访问)使用at方法和[]运算符访问单个字符。您可以使用assign方法替换整个字符串,或者使用swap方法交换两个字符串对象的内容。此外,您可以使用insert方法在指定位置插入字符,使用erase方法删除指定字符,使用clear方法删除所有字符。该类还允许您使用push_backpop_back方法将字符推送到字符串的末尾(并删除最后一个字符):

    string str = "hello"; 
    cout << str << "n"; // hello 
    str.push_back('!'); 
    cout << str << "n"; // hello! 
    str.erase(0, 1); 
    cout << str << "n"; // ello!

可以使用append方法或+=运算符向字符串的末尾添加一个或多个字符:

    string str = "hello"; 
    cout << str << "n";  // hello 
    str.append(4, '!'); 
    cout << str << "n";  // hello!!!! 
    str += " there"; 
    cout << str << "n";  // hello!!!! there

<string>库还定义了一个全局+运算符,用于将两个字符串连接成第三个字符串。

如果要更改字符串中的字符,可以使用[]运算符通过索引访问字符,并使用引用覆盖字符。您还可以使用replace方法在指定位置用来自 C 字符串或 C++字符串的字符替换一个或多个字符,或者使用通过迭代器访问的其他容器:

    string str = "hello"; 
    cout << str << "n";    // hello 
    str.replace(1, 1, "a"); 
    cout << str << "n";    // hallo

最后,您可以将字符串的一部分提取为一个新的字符串。substr方法接受一个偏移量和一个可选的计数。如果省略了字符的计数,则子字符串将从指定位置到字符串的末尾。这意味着您可以通过传递偏移量为 0 和小于字符串大小的计数来复制字符串的左侧部分,或者通过仅传递第一个字符的索引来复制字符串的右侧部分。

    string str = "one two three"; 
    string str1 = str.substr(0, 3);  
    cout << str1 << "n";          // one 
    string str2 = str.substr(8); 
    cout << str2 << "n";          // three

在此代码中,第一个示例将前三个字符复制到一个新字符串中。在第二个示例中,复制从第八个字符开始,一直到末尾。

搜索字符串

find方法可以使用字符、C 字符串或 C++字符串,并且可以提供初始搜索位置来开始搜索。find方法返回搜索文本所在的位置(而不是迭代器),如果找不到文本,则返回npos值。偏移参数和find方法的成功返回值使您能够重复解析字符串以查找特定项。find方法在正向方向搜索指定的文本,还有一个rfind方法可以在反向方向进行搜索。

请注意,rfind并不是find方法的完全相反。find方法在字符串中向前移动搜索点,并在每个点上将搜索字符串与从搜索点向前的字符进行比较(因此首先是搜索文本字符,然后是第二个字符,依此类推)。rfind方法将搜索点向移动,但比较仍然是向前进行的。因此,假设rfind方法没有给出偏移量,第一次比较将在字符串末尾的偏移量处进行,大小为搜索文本的大小。然后,通过将搜索文本中的第一个字符与搜索字符串中搜索点后面的字符进行比较来进行比较,如果成功,则将搜索文本中的第二个字符与搜索点之后的字符进行比较。因此,比较是沿着搜索点移动方向的相反方向进行的。

这变得重要,因为如果你想使用find方法的返回值作为偏移量来解析字符串,在每次搜索后,你应该将搜索偏移量向前移动,对于rfind,你应该将其向移动。

例如,要在以下字符串中搜索the的所有位置,可以调用:

    string str = "012the678the234the890"; 
    string::size_type pos = 0; 
    while(true) 
    { 
        pos++; 
        pos = str.find("the",pos); 
        if (pos == string::npos) break; 
        cout << pos << " " << str.substr(pos) << "n"; 
    } 
    // 3 the678the234the890 
    // 9 the234the890 
    // 15 the890

这将在字符位置 3、9 和 15 找到搜索文本。要向后搜索字符串,可以调用:

    string str = "012the678the234the890"; 
    string::size_type pos = string::npos; 
    while(true) 
    { 
        pos--; pos = str.rfind("the",pos); 
        if (pos == string::npos) break; 
        cout << pos << " " << str.substr(pos) << "n"; 
    } 
    // 15 the890 
    // 9 the234the890 
    // 3 the678the234the890

突出显示的代码显示了应该进行的更改,告诉你需要从末尾开始搜索并使用rfind方法。当你有一个成功的结果时,你需要在下一次搜索之前减少位置。与find方法一样,如果无法找到搜索文本,rfind方法将返回npos

有四种方法允许你搜索几个单独的字符中的一个。例如:

    string str = "012the678the234the890"; 
    string::size_type pos = str.find_first_of("eh"); 
    if (pos != string::npos) 
    { 
        cout << "found " << str[pos] << " at position "; 
        cout << pos << " " << str.substr(pos) << "n"; 
    } 
    // found h at position 4 he678the234the890

搜索字符串是ehfind_first_of将在字符串中找到eh字符时返回。在这个例子中,字符h首先在位置 4 被找到。你可以提供一个偏移参数来开始搜索,因此你可以使用find_first_of的返回值来解析字符串。find_last_of方法类似,但它以相反的方向搜索搜索文本中的一个字符。

还有两种搜索方法,它们将寻找搜索文本中提供的字符之外的字符:find_first_not_offind_last_not_of。例如:

    string str = "012the678the234the890"; 
    string::size_type pos = str.find_first_not_of("0123456789"); 
    cout << "found " << str[pos] << " at position "; 
    cout << pos << " " << str.substr(pos) << "n"; 
    // found t at position 3 the678the234the890

这段代码寻找的是除数字以外的字符,所以它在位置 3(第四个字符)找到了t

没有库函数可以从string中修剪空白,但你可以通过使用查找函数来查找非空白字符,然后将其作为substr方法的适当索引来修剪字符串左右两侧的空格。

    string str = "  hello  "; 
    cout << "|" << str << "|n";  // |  hello  | 
    string str1 = str.substr(str.find_first_not_of(" trn")); 
    cout << "|" << str1 << "|n"; // |hello  | 
    string str2 = str.substr(0, str.find_last_not_of(" trn") + 1); 
    cout << "|" << str2 << "|n"; // |  hello|

在上述代码中,创建了两个新的字符串:一个左修剪空格,另一个右修剪空格。第一个向前搜索第一个非空白字符,并将其作为子字符串的起始索引(因为没有提供计数,所以将复制所有剩余的字符串)。在第二种情况下,字符串是反向搜索的,寻找第一个非空白字符,但返回的位置将是hello的最后一个字符;因为我们需要从第一个字符开始的子字符串,所以我们增加这个索引以获得要复制的字符数。

国际化

<locale>头文件包含了用于本地化时间、日期和货币格式化的类,还提供了用于字符串比较和排序的本地化规则。

C 运行时库还具有全局函数来执行本地化。然而,在下面的讨论中,我们需要区分 C 函数和 C 语言环境。C 语言环境是默认语言环境,包括本地化规则,用于 C 和 C程序,并且可以用国家或文化的语言环境替换。C 运行时库提供了更改语言环境的函数,C标准库也提供了相同的功能。

由于 C++标准库提供了本地化的类,这意味着您可以创建多个表示语言环境的对象。语言环境对象可以在函数中创建,并且只能在那里使用,或者可以全局应用到线程,并且只能由在该线程上运行的代码使用。这与 C 本地化函数形成对比,其中更改语言环境是全局的,因此所有代码(和所有执行线程)都会受到影响。

locale类的实例可以通过类构造函数或类的静态成员创建。C++流类将使用语言环境(稍后解释),如果要更改语言环境,则在流对象上调用imbue方法。在某些情况下,您可能需要直接访问其中一个规则,并且可以通过语言环境对象访问它们。

使用 facets

国际化规则称为facets。语言环境对象是 facets 的容器,您可以使用has_facet函数测试语言环境是否具有特定的 facet;如果有,您可以通过调用use_facet函数获得 facet 的const引用。以下表格总结了七类中的六种 facet 类型。facet 类是locale::facet嵌套类的子类。

Facet type 描述
codecvt, ctype 在不同编码方案之间转换,并用于对字符进行分类和转换为大写或小写
collate 控制字符串中字符的排序和分组,包括比较和哈希字符串
messages 从目录中检索本地化消息
money 将表示货币的数字转换为字符串,反之亦然
num 将数字转换为字符串,反之亦然
time 将数字形式的时间和日期转换为字符串,反之亦然

facet 类用于将数据转换为字符串,因此它们都具有用于字符类型的模板参数。moneynumtime facets 分别由三个类表示。具有_get后缀的类处理解析字符串,而具有_put后缀的类处理格式化为字符串。对于moneynum facets,还有一个包含标点规则和符号的punct后缀的类。

由于_get facets 用于将字符序列转换为数字类型,因此类具有模板参数,您可以使用它来指示get方法将用于表示一系列字符的输入迭代器类型。同样,_put facet 类具有模板参数,您可以使用它来提供put方法将转换后的字符串写入的输出迭代器类型。对于这两种迭代器类型都提供了默认类型。

messages facet 用于与 POSIX 代码兼容。该类旨在允许您为应用程序提供本地化的字符串。其想法是,用户界面中的字符串被索引,并且在运行时,您可以通过messages facet 通过索引访问本地化的字符串。然而,Windows 应用程序通常使用使用消息编译器编译的消息资源文件。也许正因为这个原因,标准库提供的messages facet 并不起作用,但基础设施已经存在,您可以派生自己的messages facet 类。

has_facetuse_facet函数是为您想要的特定 facet 类型进行模板化的。所有 facet 类都是locale::facet类的子类,但通过这个模板参数,编译器将实例化一个返回您请求的特定类型的函数。因此,例如,如果您想要为法语区域设置格式化时间和日期字符串,可以调用以下代码:

    locale loc("french"); 
    const time_put<char>& fac = use_facet<time_put<char>>(loc);

在这里,french字符串标识了区域,这是 C 运行时库setlocale函数使用的语言字符串。第二行获取了用于将数字时间转换为字符串的 facet,因此函数模板参数是time_put<char>。这个类有一个叫做put的方法,您可以调用它来执行转换:

    time_t t = time(nullptr); 
    tm *td = gmtime(&t); 
    ostreambuf_iterator<char> it(cout); 
    fac.put(it, cout, ' ', td, 'x', '#'); 
    cout << "n";

time函数(通过<ctime>)返回一个具有当前时间和日期的整数,并使用gmtime函数将其转换为tm结构。tm结构包含年、月、日、小时、分钟和秒的各个成员。gmtime函数返回一个静态分配在函数中的结构的地址,因此您不必删除它占用的内存。facet 将tm结构中的数据格式化为字符串,通过作为第一个参数传递的输出迭代器。在这种情况下,输出流迭代器是从cout对象构造的,因此 facet 将格式化流写入控制台(第二个参数没有使用,但因为它是一个引用,您必须传递一些东西,所以在那里也使用了cout对象)。第三个参数是分隔符字符(同样,这不被使用)。第五和(可选的)第六个参数指示您需要的格式。这些是与 C 运行时库函数strftime中使用的相同格式字符相同的格式字符,作为两个单个字符,而不是 C 函数使用的格式字符串。在这个例子中,x用于获取日期,#用作获取字符串的长版本的修饰符。

代码将给出以下输出:

    samedi 28 janvier 2017

请注意,单词没有大写,也没有标点符号,还要注意顺序:星期几名称,日期,月份,然后年份。

如果locale对象构造函数参数更改为german,那么输出将是:

    Samstag, 28\. January 2017

项目的顺序与法语中的顺序相同,但单词是大写的,使用了标点符号。如果使用turkish,则结果是:

    28 Ocak 2017 Cumartesi

在这种情况下,星期几在字符串的末尾。

同一种语言分隔的两个国家将给出两个不同的字符串,以下是americanenglish-uk的结果:

    Saturday, January 28, 2017
28 January 2017

这里以时间作为示例,因为没有流,所以使用插入运算符来操作tm结构,这是一个不寻常的情况。对于其他类型,有插入运算符将它们放入流中,因此流可以使用区域设置来国际化它显示的类型。例如,您可以将double插入到cout对象中,该值将被打印到控制台。默认区域设置,美国英语,使用句号将整数部分与小数部分分开,但在其他文化中使用逗号。

imbue函数将更改本地化,直到随后调用该方法:

    cout.imbue(locale("american")); 
    cout << 1.1 << "n"; 
    cout.imbue(locale("french")); 
    cout << 1.1 << "n"; 
    cout.imbue(locale::classic());

在这里,流对象本地化为美国英语,然后浮点数1.1被打印到控制台上。接下来,本地化更改为法语,这时控制台将显示1,1。在法语中,小数点是逗号。最后一行通过传递从static classic方法返回的区域重置流对象。这将返回所谓的C 区域,这是 C 和 C++中的默认区域,是美国英语。

static方法global可以用来设置每个流对象默认使用的区域设置。当从流类创建对象时,它调用locale::global方法来获取默认区域设置。流会克隆这个对象,这样它就有了自己的独立副本,不受调用global方法设置的任何本地影响。请注意,cincout流对象是在调用main函数之前创建的,并且这些对象将使用默认的 C 区域设置,直到你使用其他区域设置。然而,重要的是要指出,一旦流被创建,global方法对流没有影响,imbue是改变流使用的区域设置的唯一方法。

global方法还将调用 C setlocale函数来改变 C 运行时库函数使用的区域设置。这很重要,因为一些 C函数(例如to_stringstod,如下文所述)将使用 C 运行时库函数来转换值。然而,C 运行时库对 C标准库一无所知,因此调用 C setlocale函数来改变默认区域设置不会影响随后创建的流对象。

值得指出的是,basic_string类使用模板参数指示的字符特性类进行字符串比较。string类使用char_traits类,它的compare方法直接比较两个字符串中对应的字符。这种比较不考虑比较字符的文化规则。如果你想进行使用文化规则的比较,可以通过collate facet 来实现:

    int compare( 
       const string& lhs, const string& rhs, const locale& loc) 
    { 
        const collate<char>& fac = use_facet<collate<char>>(loc); 
        return fac.compare( 
            &lhs[0], &lhs[0] + lhs.size(), &rhs[0], &rhs[0] + rhs.size()); 
    }

字符串和数字

标准库包含各种函数和类,用于在 C++字符串和数值之间进行转换。

将字符串转换为数字

C标准库包含了名为stodstoi的函数,它们将 C string对象转换为数值(stod转换为doublestoi转换为integer)。例如:

    double d = stod("10.5"); 
    d *= 4; 
    cout << d << "n"; // 42

这将使用值为10.5的浮点变量d进行初始化,然后在计算中使用该值,并将结果打印到控制台。输入字符串可能包含无法转换的字符。如果是这种情况,那么字符串的解析将在那一点结束。你可以提供一个指向size_t变量的指针,该变量将被初始化为无法转换的第一个字符的位置:

    string str = "49.5 red balloons"; 
    size_t idx = 0; 
    double d = stod(str, &idx); 
    d *= 2; 
    string rest = str.substr(idx); 
    cout << d << rest << "n"; // 99 red balloons

在上述代码中,idx变量将被初始化为4,表示5r之间的空格是无法转换为double的第一个字符。

将数字转换为字符串

<string>库提供了各种重载的to_string函数,用于将整数类型和浮点类型转换为string对象。这个函数不允许你提供任何格式化细节,所以对于整数,你不能指定字符串表示的基数(例如,十六进制),对于浮点数转换,你无法控制选项,比如有效数字的数量。to_string函数是一个功能有限的简单函数。更好的选择是使用流类,如下一节所述。

使用流类

可以使用cout对象(ostream类的实例)将浮点数和整数打印到控制台,也可以使用ofstream类的实例将它们写入文件。这两个类都会使用成员方法和操作符将数字转换为字符串,并影响输出字符串的格式。同样,cin对象(istream类的实例)和ifstream类可以从格式化的流中读取数据。

操纵器是接受流对象引用并返回该引用的函数。标准库具有各种全局插入运算符,其参数是流对象的引用和函数指针。适当的插入运算符将使用流对象作为其参数调用函数指针。这意味着操纵器将可以访问并操作其插入的流。对于输入流,还有具有函数参数的提取运算符,该参数将使用流对象调用函数。

C流的架构意味着在您的代码中调用流接口和获取数据的低级基础设施之间有一个缓冲区。C标准库提供了以字符串对象作为缓冲区的流类。对于输出流,您在流中插入项目后访问字符串,这意味着字符串将包含根据这些插入运算符格式化的项目。同样,您可以提供一个带有格式化数据的字符串作为输入流的缓冲区,当您使用提取运算符从流中提取数据时,实际上是解析字符串并将字符串的部分转换为数字。

此外,流类具有locale对象,流对象将调用此区域的转换部分,将字符序列从一种编码转换为另一种编码。

输出浮点数

<ios>库具有改变流处理数字方式的操纵器。默认情况下,输出流将以十进制格式打印范围在0.001100000之间的浮点数,并且对于超出此范围的数字,它将使用具有尾数和指数的科学格式。这种混合格式是defaultfloat操纵器的默认行为。如果您总是想使用科学计数法,那么应该在输出流中插入scientific操纵器。如果您想使用仅使用十进制格式显示浮点数(即小数点左侧的整数部分和右侧的小数部分),那么可以使用fixed操纵器修改输出流。可以通过调用precision方法来改变小数位数:

    double d = 123456789.987654321; 
    cout << d << "n"; 
    cout << fixed; 
    cout << d << "n"; 
    cout.precision(9); 
    cout << d << "n"; 
    cout << scientific; 
    cout << d << "n";

上述代码的输出是:

 1.23457e+08
 123456789.987654
 123456789.987654328
 1.234567900e+08

第一行显示了大数使用科学计数法。第二行显示了fixed的默认行为,即给出小数到 6 位。通过调用precision方法来改变代码,以给出 9 位小数位数(可以通过在流中插入<iomanip>库中的setprecision操纵器来实现相同的效果)。最后,格式切换到具有 9 位小数位数的尾数的科学格式。默认情况下,指数由小写e标识。如果您愿意,可以使用uppercase操纵器(和nouppercase)使其大写。请注意,小数部分存储方式意味着在具有 9 位小数位数的固定格式中,我们看到第九位数字是8而不是预期的1

您还可以指定正数是否显示+符号;showpos操纵器将显示该符号,但默认的noshowpos操纵器将不显示该符号。showpoint操纵器将确保即使浮点数是整数,也会显示小数点。默认值是noshowpoint,这意味着如果没有小数部分,则不显示小数点。

setw操纵器(在<iomanip>头文件中定义)可用于整数和浮点数。实际上,这个操纵器定义了在控制台上打印下一个(仅下一个)放置在流中的项目所占用的最小宽度:

    double d = 12.345678; 
    cout << fixed; 
    cout << setfill('#'); 
    cout << setw(15) << d << "n";

为了说明setw操纵器的效果,这段代码调用了setfill操纵器,它表示应该打印一个井号(#)而不是空格。代码的其余部分表示数字应该使用固定格式打印(默认情况下为 6 位小数),宽度为 15 个字符的空格。结果是:

    ######12.345678

如果数字是负数(或使用showpos),那么默认情况下符号将与数字一起显示;如果使用internal操纵器(在<ios>中定义),那么符号将左对齐在为数字设置的空格中:

    double d = 12.345678; 
    cout << fixed; 
    cout << showpos << internal; 
    cout << setfill('#'); 
    cout << setw(15) << d << "n";

前述代码的结果如下:

    +#####12.345678

请注意,空格右侧的+符号由井号表示。

setw操纵器通常用于允许您以格式化的列输出数据表:

    vector<pair<string, double>> table 
    { { "one",0 },{ "two",0 },{ "three",0 },{ "four",0 } }; 

    double d = 0.1; 
    for (pair<string,double>& p : table) 
    { 
        p.second = d / 17.0; 
        d += 0.1; 
    } 

    cout << fixed << setprecision(6); 

    for (pair<string, double> p : table) 
    { 
        cout << setw(6)  << p.first << setw(10) << p.second << "n"; 
    }

这将使用字符串和数字填充vector对。vector用字符串值和零初始化,然后在for循环中更改浮点数(这里实际计算无关紧要;重点是创建一些具有多位小数的数字)。数据以两列打印出来,数字以 6 位小数打印。这意味着,包括前导零和小数点,每个数字将占用 8 个空格。文本列的宽度为 6 个字符,数字列的宽度为 10 个字符。默认情况下,当指定列宽时,输出将右对齐,这意味着每个数字前面有两个空格,文本根据字符串的长度进行填充。输出如下:

 one  0.005882
 two  0.011765
 three  0.017647
 four  0.023529

如果要使列中的项目左对齐,则可以使用left操纵器。这将影响所有列,直到使用right操纵器将对齐方式更改为右对齐:

    cout << fixed << setprecision(6) << left;

这将产生以下输出:

 one   0.005882
 two   0.011765
 three 0.017647
 four  0.023529

如果您希望两列具有不同的对齐方式,则需要在打印值之前设置对齐方式。例如,要左对齐文本并右对齐数字,请使用以下方法:

    for (pair<string, double> p : table) 
    { 
        cout << setw(6) << left << p.first  
            << setw(10) << right << p.second << "n"; 
    }

前述代码的输出如下:

 one     0.005882
 two     0.011765
 three   0.017647
 four    0.023529

输出整数

整数也可以使用setwsetfill方法以列的形式打印。您可以插入操纵器以使用 8 进制(oct),10 进制(dec)和 16 进制(hex)打印整数。数字可以以指定的基数打印(以0为前缀表示八进制或以0x表示十六进制),也可以使用showbasenoshowbase操纵器。如果使用hex,则大于9的数字是字母af,默认情况下这些是小写的。如果您希望这些为大写,则可以使用uppercase操纵器(并使用nouppercase转换为小写)。

输出时间和金钱

<iomanip>中的put_time函数传递了一个用时间和日期初始化的tm结构和一个格式字符串。该函数返回_Timeobj类的一个实例。正如名称所示,您实际上并不希望创建此类的变量;相反,应该使用该函数将具有特定格式的时间/日期插入流中。有一个插入运算符将打印一个_Timeobj对象。该函数的使用方式如下:

    time_t t = time(nullptr); 
    tm *pt = localtime(&t); 
    cout << put_time(pt, "time = %X date = %x") << "n";

这将产生以下输出:

    time = 20:08:04 date = 01/02/17

该函数将使用流中的区域设置,因此如果将区域设置为流中,然后调用put_time,则时间/日期将使用格式字符串和区域设置的时间/日期本地化规则进行格式化。格式字符串使用strftime的格式标记:

    time_t t = time(nullptr); 
    tm *pt = localtime(&t); 
    cout << put_time(pt, "month = %B day = %A") << "n"; 
    cout.imbue(locale("french")); 
    cout << put_time(pt, "month = %B day = %A") << "n";

前述代码的输出是:

 month = March day = Thursday
 month = mars day = jeudi

类似地,put_money函数返回一个_Monobj对象。同样,这只是一个容器,用于传递给此函数的参数,您不应该使用此类的实例。相反,您应该将此函数插入到输出流中。实际工作发生在插入运算符中,该运算符获取当前区域设置上的货币 facet,使用它来将数字格式化为适当数量的小数位,并确定小数点字符;如果使用千位分隔符,要在适当位置插入它之前使用什么字符。

    Cout << showbase; 
    cout.imbue(locale("German")); 
    cout << "German" << "n"; 
    cout << put_money(109900, false) << "n"; 
    cout << put_money("1099", true) << "n"; 
    cout.imbue(locale("American")); 
    cout << "American" << "n"; 
    cout << put_money(109900, false) << "n"; 
    cout << put_money("1099", true) << "n";

上述代码的输出是:

 German
 1.099,00 euros
 EUR10,99
 American
 $1,099.00
 USD10.99

您可以使用double或字符串提供欧分或美分的数字,put_money函数会使用适当的小数点(德国为,,美国为.)和适当的千位分隔符(德国为.,美国为,)格式化欧元或美元的数字。将showbase操纵器插入输出流意味着put_money函数将显示货币符号,否则只会显示格式化后的数字。put_money函数的第二个参数指定使用货币字符(false)还是国际符号(true)。

使用流将数字转换为字符串

流缓冲区类负责从适当的源(文件、控制台等)获取字符并写入字符,并且是从<streambuf>中的抽象类basic_streambuf派生的。这个基类定义了两个虚拟方法,overflowunderflow,派生类重写这些方法以从与派生类关联的设备中写入和读取字符(分别)。流缓冲区类执行将项目放入流中或从流中取出项目的基本操作,由于缓冲区处理字符,因此该类使用字符类型和字符特性的参数进行模板化。

正如其名称所示,如果您使用basic_stringbuf,流缓冲区将是一个字符串,因此读取字符的源和写入字符的目的地就是该字符串。如果您使用此类为流对象提供缓冲区,这意味着您可以使用为流编写的插入或提取运算符将格式化数据写入字符串或从字符串中读取。basic_stringbuf缓冲区是可扩展的,因此在插入项目时,缓冲区将适当地扩展。有typedef,其中缓冲区是stringstringbuf)或wstringwstringbuf)。

例如,假设您有一个已定义的类,并且还定义了插入运算符,以便您可以将其与cout对象一起使用来将值打印到控制台:

    struct point 
    { 
        double x = 0.0, y = 0.0; 
        point(){} 
        point(double _x, double _y) : x(_x), y(_y) {} 
    }; 

    ostream& operator<<(ostream& out, const point& p) 
    { 
        out << "(" << p.x << "," << p.y << ")"; 
        return out; 
    }

将其与cout对象一起使用很简单--考虑以下代码片段:

    point p(10.0, -5.0); 
    cout << p << "n";         // (10,-5)

您可以使用stringbuf将格式化的输出定向到字符串而不是控制台:

    stringbuf buffer;  
    ostream out(&buffer); 
    out << p; 
    string str = buffer.str(); // contains (10,-5)

由于流对象处理格式化,这意味着您可以插入任何有插入运算符的数据类型,并且可以使用任何ostream格式化方法和任何操纵器。所有这些方法和操纵器的格式化输出将被插入到缓冲区中的字符串对象中。

另一个选项是使用<sstream>中的basic_ostringstream类。这个类是基于用作缓冲区的字符串的字符类型的模板(因此string版本是ostringstream)。它派生自ostream类,因此您可以在任何需要使用ostream对象的地方使用实例。格式化的结果可以通过str方法访问:

    ostringstream os; 
    os << hex; 
    os << 42; 
    cout << "The value is: " << os.str() << "n";

此代码以十六进制(2a)获取42的值;这是通过在流中插入hex操纵器,然后插入整数来实现的。通过调用str方法获取格式化的字符串。

使用流从字符串中读取数字

cin对象是istream类的一个实例(在<istream>库中),可以从控制台输入字符并将其转换为您指定的数字形式。ifstream类(在<ifstream>库中)还允许您从文件中输入字符并将其转换为数字形式。与输出流一样,您可以使用流类与字符串缓冲区,以便您可以从字符串对象转换为数字值。

basic_istringstream类(在<sstream>库中)是从basic_istream类派生的,因此您可以创建流对象并从这些对象中提取项目(数字和字符串)。该类在字符串对象上提供了这种流接口(typedef关键字istringstream基于stringwistringstream基于wstring)。当您构造此类的对象时,您将使用包含数字的string初始化对象,然后使用>>运算符从中提取基本内置类型的对象,就像您使用cin从控制台提取这些项目一样。

重申一下,提取运算符将空格视为流中项目之间的分隔符,因此它们将忽略所有前导空格,读取直到下一个空格的非空格字符,并尝试将此子字符串转换为适当的类型,如下所示:

    istringstream ss("-1.0e-6"); 
    double d; 
    ss >> d;

这将使用值-1e-6初始化变量d。与cin一样,您必须了解流中项目的格式;因此,如果您尝试从前面的示例中的字符串中提取double而不是提取整数,则当对象遇到小数点时,它将停止提取字符。如果字符串的一部分未转换,您可以将其余部分提取到一个字符串对象中:

    istringstream ss("-1.0e-6"); 
    int i; 
    ss >> i; 
    string str; 
    ss >> str; 
    cout << "extracted " << i << " remainder " << str << "n";

这将在控制台上打印以下内容:

    extracted -1 remainder .0e-6

如果字符串中有多个数字,则可以通过多次调用>>运算符来提取这些数字。流还支持一些操作器。例如,如果字符串中的数字是以hex格式表示的,则可以使用hex操作器通知流这一点,如下所示:

    istringstream ss("0xff"); 
    int i; 
    ss >> hex; 
    ss >> i;

这表示字符串中的数字以十六进制格式表示,变量i将被初始化为 255 的值。如果字符串包含非数字值,则流对象仍将尝试将字符串转换为适当的格式。在下面的片段中,您可以通过调用fail函数来测试此类提取是否失败:

    istringstream ss("Paul was born in 1942"); 
    int year; 
    ss >> year; 
    if (ss.fail()) cout << "failed to read number" << "n";

如果您知道字符串包含文本,可以将其提取到字符串对象中,但请记住空格字符将被视为分隔符:

    istringstream ss("Paul was born in 1942"); 
    string str; 
    ss >> str >> str >> str >> str; 
    int year; 
    ss >> year;

在这里,数字之前有四个单词,因此代码会四次读取string。如果您不知道字符串中的数字在哪里,但知道字符串中有一个数字,您可以将内部缓冲区指针移动到指向数字的位置:

    istringstream ss("Paul was born in 1942"); 
    string str;    
    while (ss.eof() && !(isdigit(ss.peek()))) ss.get(); 
    int year; 
    ss >> year; 
    if (!ss.fail()) cout << "the year was " << year << "n";

peek方法返回当前位置的字符,但不移动缓冲区指针。此代码检查此字符是否为数字,如果不是,则通过调用get方法移动内部缓冲区指针。(此代码测试eof方法以确保在缓冲区结束后没有尝试读取字符。)如果您知道数字从哪里开始,那么可以调用seekg方法将内部缓冲区指针移动到指定位置。

<istream>库中有一个名为ws的操作器,用于从流中删除空格。回想一下我们之前说过,没有函数可以从字符串中删除空格。这是因为ws操作器从中删除空格,而不是从字符串中删除空格,但由于您可以使用字符串作为流的缓冲区,这意味着您可以间接使用此函数从字符串中删除空格:

    string str = "  hello  "; 
    cout << "|" << str1 << "|n"; // |  hello  | 
    istringstream ss(str); 
    ss >> ws; 
    string str1; 
    ss >> str1; 
    ut << "|" << str1 << "|n";   // |hello|

ws函数本质上是通过输入流中的项进行迭代,并在遇到非空白字符时返回。如果流是文件或控制台流,则ws函数将从这些流中读取字符;在这种情况下,缓冲区由已分配的字符串提供,因此它会跳过字符串开头的空白。请注意,流类将后续的空格视为流中值之间的分隔符,因此在此示例中,流将从缓冲区中读取字符,直到遇到空格,并且本质上会左-和右-修剪字符串。但这不一定是您想要的。如果您有一个由空格填充的多个单词的字符串,此代码将只提供第一个单词。

<iomanip>库中的get_moneyget_time操作允许您使用货币和时间 facet 从字符串中提取货币和时间:

    tm indpday = {}; 
    string str = "4/7/17"; 
    istringstream ss(str); 
    ss.imbue(locale("french")); 
    ss >> get_time(&indpday, "%x"); 
    if (!ss.fail())  
    { 
       cout.imbue(locale("american")); 
       cout << put_time(&indpday, "%x") << "n";  
    }

在上述代码中,流首先以法国格式(日/月/年)初始化,并使用区域设置的标准日期表示提取日期。日期被解析为tm结构,然后使用put_time以美国区域设置的标准日期表示打印出来。结果是:

    7/4/2017

使用正则表达式

正则表达式是文本模式,可以被正则表达式解析器用来搜索与模式匹配的字符串,并且如果需要,用其他文本替换匹配的项。

定义正则表达式

正则表达式regex)由定义模式的字符组成。表达式包含对解析器有意义的特殊符号,如果要在表达式的搜索模式中使用这些符号,则可以用反斜杠(\)对其进行转义。您的代码通常将表达式作为string对象传递给regex类的实例作为构造函数参数。然后将该对象传递给<regex>中的函数,该函数将使用表达式来解析文本以匹配模式的序列。

下表总结了您可以使用regex类匹配的一些模式。

模式 解释 例子
文字 匹配确切的字符 li匹配flip lip plier
[group] 匹配组中的单个字符 [at]匹配cat, cat, top, pear
[^group] 匹配不在组中的单个字符 [^at]匹配cat, top, top, pear, pear, pear
[first-last] 匹配范围在firstlast之间的任何字符 [0-9]匹配数字102, 102, 102
元素精确匹配 n 次 91{2}匹配911
{n,} 元素匹配 n 次或更多次 wel{1,}匹配wellwelcome
{n,m} 元素匹配 n 到 m 次 9{2,4}匹配99, 999, 9999, 99999 但不匹配 9
. 通配符,除了n之外的任何字符 a.e匹配ateare
* 元素匹配零次或多次 d*.d匹配.1, 0.1, 10.1但不匹配 10
+ 元素匹配一次或多次 d*.d匹配0.1, 10.1但不匹配 10 或.1
? 元素匹配零次或一次 tr?ap匹配traptap
| 匹配由|分隔的元素中的任何一个 th(e&#124;is&#124;at)匹配the, this, that
[[:class:]] 匹配字符类 [[:upper:]]匹配大写字符:I am Richard
n 匹配换行符
s 匹配任何单个空格
匹配任何单个数字的d[0-9]
w 匹配可以是单词中的字符(大写和小写字符)
b 在字母数字字符和非字母数字字符之间的边界处匹配 d{2}b匹配 999和 9999 bd{2}匹配999 和9999
$ 行尾 s$ 匹配行尾的单个空格
^ 行首 ^d 如果一行以数字开头则匹配

您可以使用正则表达式来定义要匹配的模式--Visual C++编辑器允许您在搜索对话框中执行此操作(这是一个很好的测试平台,可以用来开发您的表达式)。

定义要匹配的模式要比定义要匹配的模式容易得多。例如,表达式w+b<w+>将匹配字符串"vector<int>",因为它有一个或多个单词字符,后面跟着一个非单词字符(<),然后是一个或多个单词字符,后面跟着>。这个模式将不会匹配字符串"#include <regex>",因为include后面有一个空格,而b表示字母数字字符和非字母数字字符之间有一个边界。

表中的th(e|is|at)示例表明,当您想要提供替代方案时,可以使用括号来分组模式。然而,括号还有另一个用途--它们允许您捕获组。因此,如果您想要执行替换操作,可以搜索一个模式作为一个组,然后稍后引用该组作为一个命名的子组(例如,搜索(Joe)以便您可以用Tom替换Joe)。您还可以在表达式中引用由括号指定的子表达式(称为反向引用):

    ([A-Za-z]+) +1

这个表达式的意思是:搜索具有 a 到 z 和 A 到 Z 范围内一个或多个字符的单词;这个单词称为 1,所以找到它出现两次并且中间有一个空格

标准库类

要执行匹配或替换,您必须创建一个正则表达式对象。这是一个basic_regex类的对象,它具有字符类型和正则表达式特性类的模板参数。对于这个类有两个typedefregex用于charwregex用于宽字符,它们的特性由regex_traitswregex_traits类描述。

特性类确定了正则表达式类如何解析表达式。例如,从之前的文本中可以看到,您可以使用w表示单词,d表示数字,s表示空白字符。[[::]]语法允许您为字符类使用更具描述性的名称:alnumdigitlower等等。由于这些是依赖于字符集的文本序列,特性类将具有适当的代码来测试表达式是否使用了支持的字符类。

适当的正则表达式类将解析表达式,以便<regex>库中的函数可以使用表达式来识别文本中的模式:

    regex rx("([A-Za-z]+) +1");

这使用反向引用来搜索重复的单词。请注意,正则表达式使用1作为反向引用,但在字符串中反斜杠必须转义(\)。如果您使用字符类,如sd,那么您将需要进行大量的转义。相反,您可以使用原始字符串(R"()"),但请记住,引号内的第一组括号是原始字符串的语法的一部分,而不是正则表达式组的一部分:

    regex rx(R"(([A-Za-z]+) +1)");

哪种更易读完全取决于您;两者都在双引号内引入了额外的字符,这可能会让人迅速浏览时对正则表达式匹配的内容产生困惑。

请记住,正则表达式本质上是一个程序,因此regex解析器将确定该表达式是否有效,如果无效,构造函数将抛出regex_error类型的异常。异常处理将在下一章中解释,但重要的是要指出,如果未捕获异常,它将导致应用程序在运行时中止。异常的what方法将返回错误的基本描述,code方法将返回regex_constants命名空间中error_type枚举中的常量之一。没有指示错误发生在表达式的哪个位置。您应该在外部工具中彻底测试您的表达式(例如 Visual C++搜索)。

构造函数可以接受一个字符串(C 或 C ++)或一对迭代器,用于字符串(或其他容器)中字符范围,或者您可以传递一个初始化列表,其中列表中的每个项目都是一个字符。正则表达式的语言有各种不同的风格;basic_regex类的默认语言是ECMAScript。如果您想要不同的语言(基本 POSIX,扩展 POSIX,awk,grep 或 egrep),可以将syntax_option_type枚举中定义的常量之一(basic_regex类中定义的常量的副本也可用)作为构造函数参数。您只能指定一种语言风格,但可以将其与其他syntax_option_type常量结合使用:icase指定不区分大小写,collate使用匹配中的区域设置,nosubs表示您不想捕获组,optimize优化匹配。

该类使用getloc方法获取解析器使用的区域设置,并使用imbue重置区域设置。如果您imbue一个区域设置,那么在使用assign方法重置之前,您将无法使用regex对象进行任何匹配。这意味着有两种使用regex对象的方法。如果要使用当前区域设置,则将正则表达式传递给构造函数:如果要使用不同的区域设置,则使用默认构造函数创建一个空的regex对象,然后使用imbue方法传递区域设置,并使用assign方法传递正则表达式。一旦解析了正则表达式,就可以调用mark_count方法获取表达式中捕获组的数量(假设您没有使用nosubs)。

匹配表达式

一旦构造了regex对象,就可以将其传递给<regex>库中的方法,以在字符串中搜索模式。regex_match函数接受一个字符串(C 或 C ++)或容器中字符范围的迭代器,并构造了一个regex对象。在其最简单的形式中,该函数仅在存在精确匹配时返回true,即表达式与搜索字符串完全匹配:

    regex rx("[at]"); // search for either a or t 
    cout << boolalpha; 
    cout << regex_match("a", rx) << "n";  // true 
    cout << regex_match("a", rx) << "n";  // true 
    cout << regex_match("at", rx) << "n"; // false

在前面的代码中,搜索表达式是给定范围内的单个字符(at),因此前两次调用regex_match返回true,因为搜索的字符串是一个字符。最后一次调用返回false,因为匹配与搜索的字符串不同。如果在正则表达式中删除[],那么只有第三次调用返回true,因为您要查找确切的字符串at。如果正则表达式是[at]+,以便查找一个或多个字符at,那么所有三次调用都返回true。您可以通过传递match_flag_type枚举中的一个或多个常量来更改匹配的确定方式。

如果将match_results对象的引用传递给此函数,那么在搜索之后,该对象将包含有关匹配位置和字符串的信息。match_results对象是sub_match对象的容器。如果函数成功,这意味着整个搜索字符串与表达式匹配,在这种情况下,返回的第一个sub_match项将是整个搜索字符串。如果表达式有子组(用括号标识的模式),那么这些子组将是match_results对象中的其他sub_match对象。

    string str("trumpet"); 
    regex rx("(trump)(.*)"); 
    match_results<string::const_iterator> sm; 
    if (regex_match(str, sm, rx)) 
    { 
        cout << "the matches were: "; 
        for (unsigned i = 0; i < sm.size(); ++i)  
        { 
            cout << "[" << sm[i] << "," << sm.position(i) << "] "; 
        } 
        cout << "n"; 
    } // the matches were: [trumpet,0] [trump,0] [et,5]

在这里,表达式是字面量trump后面跟着任意数量的字符。整个字符串与此表达式匹配,并且有两个子组:字面字符串trump和在trump被移除后剩下的任何内容。

match_results类和sub_match类都是基于用于指示匹配项的迭代器类型的模板。有typedef调用cmatchwcmatch,其中模板参数是const char*const wchar_t*smatchwsmatch,其中参数是在stringwstring对象中使用的迭代器,分别(类似地,还有子匹配类:csub_matchwcsub_matchssub_matchwssub_match)。

regex_match函数可能会非常严格,因为它寻找模式和搜索字符串之间的精确匹配。regex_search函数更加灵活,因为它在搜索字符串中返回true,如果有一个子字符串与表达式匹配。请注意,即使在搜索字符串中有多个匹配,regex_search函数也只会找到第一个。如果要解析字符串,必须多次调用该函数,直到指示没有更多匹配为止。这就是迭代器访问搜索字符串的重载变得有用的地方:

    regex rx("bd{2}b"); 
    smatch mr; 
    string str = "1 4 10 42 100 999"; 
    string::const_iterator cit = str.begin(); 
    while (regex_search(cit, str.cend(), mr, rx)) 
    { 
        cout << mr[0] << "n"; 
        cit += mr.position() + mr.length(); 
    }

在这里,表达式将匹配由空格包围的两位数(d{2}),两个b模式意味着在trump之前和之后的边界。循环从指向字符串开头的迭代器开始,当找到匹配时,该迭代器将增加到该位置,然后增加匹配的长度。regex_iterator对象,进一步解释了这种行为。

match_results类提供了对包含的sub_match对象的迭代器访问,因此可以使用范围for。最初,似乎容器的工作方式有些奇怪,因为它知道sub_match对象在搜索字符串中的位置(通过position方法,该方法接受子匹配对象的索引),但是sub_match对象似乎只知道它所引用的字符串。然而,仔细检查sub_match类后,发现它是从pair派生而来的,其中两个参数都是字符串迭代器。这意味着sub_match对象具有指定原始字符串中子字符串范围的迭代器。match_result对象知道原始字符串的起始位置,并且可以使用sub_match.first迭代器来确定子字符串的起始字符位置。

match_result对象具有[]运算符(和str方法),返回指定组的子字符串;这将是一个使用原始字符串中字符范围的迭代器构造的字符串。prefix方法返回匹配之前的字符串,suffix方法返回匹配之后的字符串。因此,在前面的代码中,第一个匹配将是10,前缀将是1 4,后缀将是42 100 999。相比之下,如果访问sub_match对象本身,它只知道自己的长度和字符串,这是通过调用str方法获得的。

match_result对象还可以通过format方法返回结果。这需要一个格式字符串,其中匹配的组通过以$符号标识的编号占位符($1$2等)进行识别。输出可以是流,也可以从方法中作为字符串返回:

    string str("trumpet"); 
    regex rx("(trump)(.*)"); 
    match_results<string::const_iterator> sm; 
    if (regex_match(str, sm, rx)) 
    { 
        string fmt = "Results: [$1] [$2]"; 
        cout << sm.format(fmt) << "n"; 
    } // Results: [trump] [et]

使用regex_matchregex_search,您可以使用括号标识子组。如果模式匹配,则可以使用适当的match_results对象通过引用传递给函数来获取这些子组。如前所示,match_results对象是sub_match对象的容器。子匹配可以使用<!===<=>>=运算符进行比较,比较迭代器指向的项目(即子字符串)。此外,sub_match对象可以插入到流中。

使用迭代器

该库还提供了用于正则表达式的迭代器类,它提供了一种不同的解析字符串的方式。由于该类涉及字符串的比较,因此它是使用元素类型和特性进行模板化的。该类需要通过字符串进行迭代,因此第一个模板参数是字符串迭代器类型,元素和特性类型可以从中推导出来。regex_iterator类是一个前向迭代器,因此它具有++运算符,并且提供了一个*运算符,可以访问match_result对象。在先前的代码中,您看到match_result对象被传递给regex_matchregex_search函数,它们用它来包含它们的结果。这引发了一个问题,即是什么代码填充了通过regex_iterator访问的match_result对象。答案在于迭代器的++运算符:

    string str = "the cat sat on the mat in the bathroom"; 
    regex rx("(b(.at)([^ ]*)"); 
    regex_iterator<string::iterator> next(str.begin(), str.end(), rx); 
    regex_iterator<string::iterator> end; 

    for (; next != end; ++next) 
    { 
        cout << next->position() << " " << next->str() << ", "; 
    } 
    cout << "n"; 
    // 4 cat, 8 sat, 19 mat, 30 bathroom

在这段代码中,搜索一个字符串,其中第二个和第三个字母是atb表示模式必须位于单词的开头(.表示单词可以以任何字母开头)。这三个字符周围有一个捕获组,另一个捕获组是除空格以外的一个或多个字符。

迭代器对象next是使用要搜索的字符串和regex对象的迭代器构造的。++运算符本质上调用regex_search函数,同时保持执行下一个搜索的位置。如果搜索未能找到模式,则运算符将返回序列结束迭代器,这是由默认构造函数创建的迭代器(在此代码中为end对象)。此代码打印出完整的匹配,因为我们使用了str方法的默认参数(0)。如果要获取实际匹配的子字符串,请使用str(1),结果将是:

    4 cat, 8 sat, 19 mat, 30 bat

由于*(和->)运算符可以访问match_result对象,因此还可以访问prefix方法以获取匹配之前的字符串,suffix方法将返回匹配后的字符串。

regex_iterator类允许您迭代匹配的子字符串,而regex_token_iterator更进一步,它还可以让您访问所有子匹配项。在使用时,这个类与regex_iterator相同,只是在构造时不同。regex_token_iterator构造函数有一个参数,指示您希望通过*运算符访问哪个子匹配。值为-1表示您想要前缀,值为0表示您想要整个匹配,值为1或更高表示您想要编号的子匹配。如果愿意,可以传递一个带有您想要的子匹配类型的int vector或 C 数组:

    using iter = regex_token_iterator<string::iterator>; 
    string str = "the cat sat on the mat in the bathroom"; 
    regex rx("b(.at)([^ ]*)");  
    iter next, end; 

    // get the text between the matches 
    next = iter(str.begin(), str.end(), rx, -1); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // the ,  ,  on the ,  in the , 

    // get the complete match 
    next = iter(str.begin(), str.end(), rx, 0); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // cat, sat, mat, bathroom, 

    // get the sub match 1 
    next = iter(str.begin(), str.end(), rx, 1); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // cat, sat, mat, bat, 

    // get the sub match 2 
    next = iter(str.begin(), str.end(), rx, 2); 
    for (; next != end; ++next) cout << next->str() << ", "; 
    cout << "n"; 
    // , , , hroom,

替换字符串

regex_replace 方法类似于其他方法,它接受一个字符串(C 字符串或 C++ string 对象,或字符范围的迭代器)、一个 regex 对象和可选标志。此外,该函数有一个格式字符串并返回一个 string。格式字符串基本上传递给正则表达式匹配结果的每个 results_match 对象的 format 方法。然后,将此格式化字符串用作相应匹配的替换。如果没有匹配,则返回搜索字符串的副本。

    string str = "use the list<int> class in the example"; 
    regex rx("b(list)(<w*> )"); 
    string result = regex_replace(str, rx, "vector$2"); 
    cout << result << "n"; // use the vector<int> class in the example

在上述代码中,我们说整个匹配的字符串(应该是 list< 后跟一些文本,然后是 > 和一个空格)应该被替换为 vector,,后跟第二个子匹配(< 后跟一些文本,然后是 > 和一个空格)。结果是 list<int> 将被替换为 vector<int>

使用字符串

示例将作为文本文件读取并处理电子邮件。互联网消息格式的电子邮件将分为两部分:头部和消息正文。这是简单的处理,因此不会尝试处理 MIME 电子邮件正文格式(尽管此代码可以用作该处理的起点)。电子邮件正文将在第一个空行之后开始,互联网标准规定行不应超过 78 个字符。如果超过,它们不得超过 998 个字符。这意味着换行符(回车、换行对)用于保持此规则,并且段落的结束由空行表示。

头部更加复杂。在最简单的形式中,头部在单独的一行上,格式为 name:value。头部名称与头部值之间由冒号分隔。头部可以使用称为折叠空格的格式分成多行,其中分隔头部的换行符放置在空格(空格、制表符等)之前。这意味着以空格开头的行是前一行头部的延续。头部通常包含由分号分隔的 name=value 对,因此能够分隔这些子项目是有用的。有时这些子项目没有值,也就是说,将由分号终止的子项目。

示例将把电子邮件作为一系列字符串,并使用这些规则创建一个包含头部集合和包含正文的字符串的对象。

创建项目

为项目创建一个文件夹,并创建一个名为 email_parser.cpp 的 C++ 文件。由于此应用程序将读取文件并处理字符串,因此添加适当库的包含,并添加代码以从命令行获取文件名:

    #include <iostream> 
    #include <fstream> 
    #include <string> 

    using namespace std; 

    void usage() 
    { 
        cout << "usage: email_parser file" << "n"; 
        cout << "where file is the path to a file" << "n"; 
    } 

    int main(int argc, char *argv[]) 
    { 
        if (argc <= 1) 
        { 
            usage(); 
            return 1; 
        } 

        ifstream stm; 
        stm.open(argv[1], ios_base::in); 
        if (!stm.is_open()) 
        { 
            usage(); 
            cout << "cannot open " << argv[1] << "n"; 
            return 1; 
        } 

        return 0; 
    }

头部将有一个名称和一个正文。正文可以是单个字符串,也可以是一个或多个子项目。创建一个表示头部正文的类,并暂时将其视为单行。在 usage 函数之前添加以下类:

    class header_body 
    { 
        string body; 
    public: 
        header_body() = default; 
        header_body(const string& b) : body(b) {} 
        string get_body() const { return body; } 
    };

这只是将类封装在一个 string 周围;稍后我们将添加代码来分离 body 数据成员中的子项目。现在创建一个表示电子邮件的类。在 header_body 类之后添加以下代码:

    class email 
    { 
        using iter = vector<pair<string, header_body>>::iterator; 
        vector<pair<string, header_body>> headers; 
        string body; 

    public: 
        email() : body("") {} 

        // accessors 
        string get_body() const { return body; } 
        string get_headers() const; 
        iter begin() { return headers.begin(); } 
        iter end() { return headers.end(); } 

        // two stage construction 
        void parse(istream& fin); 
    private: 
        void process_headers(const vector<string>& lines); 
    };

headers 数据成员保存头部作为名称/值对。项目存储在 vector 中而不是 map 中,因为当电子邮件从邮件服务器传递到邮件服务器时,每个服务器可能会添加已存在于电子邮件中的头部,因此头部是重复的。我们可以使用 multimap,但是我们将失去头部的顺序,因为 multimap 将按照有助于搜索项目的顺序存储项目。

vector 保持容器中插入的项目的顺序,因此,由于我们将按顺序解析电子邮件,这意味着 headers 数据成员将按照电子邮件中的顺序包含头部项目。添加适当的包含以便您可以使用 vector 类。

正文和标题有单个字符串的访问器。此外,还有访问器从headers数据成员返回迭代器,以便外部代码可以遍历headers数据成员(此类的完整实现将具有允许您按名称搜索标题的访问器,但在此示例的目的上,只允许迭代)。

该类支持两阶段构造,其中大部分工作是通过将输入流传递给parse方法来完成的。parse方法将电子邮件作为vector对象中的一系列行读入,并调用一个私有函数process_headers来解释这些行作为标题。

get_headers方法很简单:它只是遍历标题,并以name: value的格式将一个标题放在每一行。添加内联函数:

    string get_headers() const 
    { 
        string all = ""; 
        for (auto a : headers) 
        { 
            all += a.first + ": " + a.second.get_body(); 
            all += "n"; 
        } 
        return all; 
    }

接下来,您需要从文件中读取电子邮件并提取正文和标题。main函数已经有打开文件的代码,因此创建一个email对象,并将文件的ifstream对象传递给parse方法。现在使用访问器打印出解析后的电子邮件。在main函数的末尾添加以下内容:

 email eml; eml.parse(stm); cout << eml.get_headers(); cout << "n"; cout << eml.get_body() << "n"; 

        return 0; 
    }

email类声明之后,添加parse函数的定义:

    void email::parse(istream& fin) 
    { 
        string line; 
        vector<string> headerLines; 
        while (getline(fin, line)) 
        { 
            if (line.empty()) 
            { 
                // end of headers 
                break; 
            } 
            headerLines.push_back(line); 
        } 

        process_headers(headerLines); 

        while (getline(fin, line)) 
        { 
            if (line.empty()) body.append("n"); 
            else body.append(line); 
        } 
    }

该方法很简单:它重复调用<string>库中的getline函数来读取string,直到检测到换行符。在方法的前半部分,字符串存储在vector中,然后传递给process_headers方法。如果读入的字符串为空,这意味着已读取空行--在这种情况下,所有标题都已读取。在方法的后半部分,读入电子邮件的正文。getline函数将剥离用于将电子邮件格式化为 78 个字符行长度的换行符,因此循环仅将行附加为一个字符串。如果读入空行,则表示段落结束,因此在正文字符串中添加换行符。

parse方法之后,添加process_headers方法:

    void email::process_headers(const vector<string>& lines) 
    { 
        string header = ""; 
        string body = ""; 
        for (string line : lines) 
        { 
            if (isspace(line[0])) body.append(line); 
            else 
            { 
                if (!header.empty()) 
                { 
                    headers.push_back(make_pair(header, body)); 
                    header.clear(); 
                    body.clear(); 
                } 

                size_t pos = line.find(':'); 
                header = line.substr(0, pos); 
                pos++; 
                while (isspace(line[pos])) pos++; 
                body = line.substr(pos); 
            } 
        } 

        if (!header.empty()) 
        { 
            headers.push_back(make_pair(header, body)); 
        } 
    }

该代码遍历集合中的每一行,并在具有完整标题时将字符串拆分为名称/正文对。在循环内,第一行测试第一个字符是否为空格;如果不是,则检查header变量是否有值;如果有,则将名称/正文对存储在类headers数据成员中,然后清除headerbody变量。

以下代码对从集合中读取的行进行操作。此代码假定这是标题行的开头,因此在该点搜索冒号并在此处拆分字符串。冒号之前是标题的名称,冒号之后是标题的正文(去除了前导空格)。由于我们不知道标题正文是否会折叠到下一行,因此不存储名称/正文;相反,允许while循环重复一次,以便测试下一行的第一个字符是否是空格,如果是,则将其附加到正文。将名称/正文对保持到while循环的下一次迭代的操作意味着最后一行不会存储在循环中,因此在方法的末尾有一个测试,以查看header变量是否为空,如果不是,则存储名称/正文对。

现在您可以编译代码(记得使用/EHsc开关)来测试是否有拼写错误。要测试代码,您应该将电子邮件客户端中的电子邮件保存为文件,然后使用该文件的路径运行email_parser应用程序。以下是互联网消息格式 RFC 5322 中给出的一个示例电子邮件消息,您可以将其放入文本文件中以测试代码:

    Received: from x.y.test
 by example.net
 via TCP
 with ESMTP
 id ABC12345
 for <mary@example.net>;  21 Nov 1997 10:05:43 -0600
Received: from node.example by x.y.test; 21 Nov 1997 10:01:22 -0600
From: John Doe <jdoe@node.example>
To: Mary Smith <mary@example.net>
Subject: Saying Hello
Date: Fri, 21 Nov 1997 09:55:06 -0600
Message-ID: <1234@local.node.example>

This is a message just to say hello.
So, "Hello".

您可以通过电子邮件消息测试应用程序,以显示解析已考虑到标题格式,包括折叠空格。

处理标题子项

下一步是将标题主体处理为子项。为此,请在 header_body 类的 public 部分添加以下突出声明:

    public: 
        header_body() = default; 
        header_body(const string& b) : body(b) {} 
        string get_body() const { return body; } 
        vector<pair<string, string>> subitems(); 
    };

每个子项将是一个名称/值对,由于子项的顺序可能很重要,因此子项存储在 vector 中。更改 main 函数,删除对 get_headers 的调用,而是逐个打印每个标题:

    email eml; 
    eml.parse(stm); 
    for (auto header : eml) { cout << header.first << " : "; vector<pair<string, string>> subItems = header.second.subitems(); if (subItems.size() == 0) { cout << header.second.get_body() << "n"; } else { cout << "n"; for (auto sub : subItems) { cout << "   " << sub.first; if (!sub.second.empty()) 
                cout << " = " << sub.second;         
                cout << "n"; } } } 
    cout << "n"; 
    cout << eml.get_body() << endl;

由于 email 类实现了 beginend 方法,这意味着范围 for 循环将调用这些方法以访问 email::headers 数据成员上的迭代器。每个迭代器将提供对 pair<string,header_body> 对象的访问,因此在此代码中,我们首先打印出标题名称,然后访问 header_body 对象上的子项。如果没有子项,标题仍将有一些文本,但不会被拆分为子项,因此我们调用 get_body 方法获取要打印的字符串。如果有子项,则将其打印出来。某些项目将具有主体,而其他项目则没有。如果项目有主体,则以 name = value 的形式打印子项。

最后的操作是解析标题主体以将其拆分为子项。在 header_body 类下面,添加该方法的定义:

    vector<pair<string, string>> header_body::subitems() 
    { 
        vector<pair<string, string>> subitems; 
        if (body.find(';') == body.npos) return subitems; 

        return subitems; 
    }

由于子项使用分号分隔,因此可以简单地测试 body 字符串上的分号。如果没有分号,则返回一个空的 vector

现在,代码必须重复解析字符串,提取子项。有几种情况需要解决。大多数子项将采用 name=value;, 的形式,因此必须提取此子项并在等号字符处拆分,并丢弃分号。

一些子项没有值,其形式为 name;,在这种情况下,分号被丢弃,并且为子项值存储了一个空字符串。最后,标题中的最后一个项目可能没有以分号结尾,因此必须考虑这一点。

添加以下 while 循环:

    vector<pair<string, string>> subitems; 
    if (body.find(';') == body.npos) return subitems; 
    size_t start = 0;
 size_t end = start; while (end != body.npos){}

正如名称所示,start 变量是子项的起始索引,end 是子项的结束索引。第一步是忽略任何空格,因此在 while 循环中添加:

    while (start != body.length() && isspace(body[start])) 
    { 
        start++; 
    } 
    if (start == body.length()) break;

这只是在引用空格字符时递增 start 索引,只要它尚未达到字符串的末尾。如果达到字符串的末尾,这意味着没有更多的字符,因此循环结束。

接下来,添加以下内容以搜索 =; 字符并处理其中一个搜索情况:

    string name = ""; 
    string value = ""; 
    size_t eq = body.find('=', start); 
    end = body.find(';', start); 

    if (eq == body.npos) 
    { 
        if (end == body.npos) name = body.substr(start); 
        else name = body.substr(start, end - start); 
    } 
    else 
    {
    } 
    subitems.push_back(make_pair(name, value)); 
    start = end + 1;

find 方法如果找不到搜索的项目,将返回 npos 值。第一次调用查找 = 字符,第二次调用查找分号。如果找不到 =,则该项目没有值,只有一个名称。如果找不到分号,则意味着 name 是从 start 索引到字符串末尾的整个字符串。如果有分号,则 name 是从 start 索引到 end 指示的索引(因此要复制的字符数是 end-start)。如果找到 = 字符,则需要在此处拆分字符串,稍后将显示该代码。一旦给定了 namevalue 变量的值,它们将被插入到 subitems 数据成员中,并且 start 索引将移动到 end 索引之后的字符。如果 end 索引是 npos,则 start 索引的值将无效,但这并不重要,因为 while 循环将测试 end 索引的值,并且如果索引是 npos,则会中断循环。

最后,需要添加当子项中有 = 字符时的代码。添加以下突出显示的文本:

    if (eq == body.npos) 
    { 
        if (end == body.npos) name = body.substr(start); 
        else name = body.substr(start, end - start); 
    } 
    else 
    { 
 if (end == body.npos) { name = body.substr(start, eq - start); value = body.substr(eq + 1); } else { if (eq < end) { name = body.substr(start, eq - start); value = body.substr(eq + 1, end - eq - 1); } else { name = body.substr(start, end - start); } } 
    }

第一行测试是否搜索分号失败。在这种情况下,名称是从start索引到等号字符之前的字符,值是等号后的文本直到字符串的末尾。

如果等号和分号字符有有效的索引,那么还有一种情况需要检查。可能等号字符的位置在分号之后,这种情况下意味着这个子项没有值,等号字符将用于后续子项。

在这一点上,您可以编译代码并使用包含电子邮件的文件进行测试。程序的输出应该是电子邮件分成标题和正文,每个标题分成子项,可以是简单的字符串或name=value对。

摘要

在本章中,您已经看到了支持字符串的各种 C++标准库类。您已经了解了如何从流中读取字符串,如何将字符串写入流,如何在数字和字符串之间进行转换,以及如何使用正则表达式来操作字符串。当您编写代码时,您将不可避免地花时间运行代码,以检查它是否符合您的规范。这将涉及提供检查算法结果的代码,将中间代码记录到调试设备的代码,当然还有在调试器下运行代码。下一章将全面讨论调试代码!

第十章:诊断和调试

软件是复杂的;无论你设计代码有多好,总有一天你将不得不调试它,无论是在开发代码的正常测试阶段还是在发出错误报告时。最好设计代码,使测试和调试尽可能简单直接。这意味着添加跟踪和报告代码,确定不变式和前后条件,以便你有一个测试代码的起点,并编写具有可理解和有意义的错误代码的函数。

准备你的代码

C++和 C 标准库有各种函数,允许你应用跟踪和报告函数,以便你可以测试代码是否以预期的方式处理数据。许多这些功能使用条件编译,以便报告仅在调试构建中发生,但如果你提供有意义的消息,它们将成为你代码的一部分文档。在你可以报告代码的行为之前,你首先必须知道从中期望什么。

不变式和条件

类不变式是条件,对象状态,你知道保持不变。在方法调用期间,对象状态将发生变化,可能变为使对象无效的东西,但一旦公共方法完成,对象状态必须保持一致。用户调用类的方法的顺序没有保证,甚至他们是否调用方法,因此对象必须可以使用无论用户调用哪些方法。对象的不变方面适用于方法调用级别:在方法调用之间,对象必须保持一致和可用。

例如,想象一下你有一个代表日期的类:它保存了 1 到 31 之间的日期,1 到 12 之间的月份,以及年份。类不变式是,无论你对日期类的对象做什么,它始终保持有效的日期。这意味着用户可以安全地使用你的日期类的对象。这也意味着类的其他方法(比如,确定两个日期之间有多少天的方法,operator-)可以假定日期对象中的值是有效的,因此这些方法不必检查它们所作用的数据的有效性。

然而,一个有效的日期不仅仅是 1 到 31 的范围和 1 到 12 的月份,因为并非每个月都有 31 天。因此,如果你有一个有效的日期,比如 1997 年 4 月 5 日,然后你调用set_day方法将日期设置为 31 号,那么类不变条件就被违反了,因为 4 月 31 日不是一个有效的日期。如果你想改变日期对象中的值,唯一安全的方法是同时改变所有的值:日期、月份和年份,因为这是保持类不变性的唯一方法。

一种方法是在调试构建中定义一个私有方法,测试类的不变条件,并确保使用断言(稍后见)维护不变条件。你可以在公开可访问的方法离开之前调用这样的方法,以确保对象保持一致状态。方法还应该有定义的前后条件。前置条件是在调用方法之前你要求为真的条件,后置条件是在方法完成后你保证为真的条件。对于类的方法,类不变式是前置条件(因为在调用方法之前对象的状态应该是一致的),不变式也是后置条件(因为方法完成后对象状态应该是一致的)。

还有一些是方法调用者的先决条件。先决条件是调用者确保的一个已记录的责任。例如,日期类将有一个先决条件,即日期数字在 1 和 31 之间。这简化了类代码,因为接受日期数字的方法可以假定传递的值永远不会超出范围(尽管由于某些月份少于 31 天,值可能仍然无效)。同样,在调试构建中,您可以使用断言来检查这些先决条件是否为真,并且断言中的测试将在发布构建中被编译掉。在方法的末尾将有后置条件,即将保持类不变量(并且对象的状态将有效),并且返回值将有效。

条件编译

如第一章中所述,从 C++开始,当编译您的 C程序时,有一个预编译步骤,将 C源文件中包含的所有文件汇总到一个单个文件中,然后进行编译。预处理器还会展开宏,并根据符号的值包含一些代码和排除其他代码。

在其最简单的形式中,条件编译使用#ifdef#endif(可选使用#else)将代码括在其中,因此只有在指定的符号已定义时才编译这些指令之间的代码。

    #ifdef TEST 
       cout << "TEST defined" << endl;     
    #else 
       cout << "TEST not defined" << endl; 
    #endif

您可以确保只编译这些行中的一个,并且至少会编译其中一个。如果定义了符号TEST,则将编译第一行,并且在编译器看来,第二行不存在。如果未定义符号TEST,则将编译第二行。如果要以相反的顺序输入这些行,可以使用#ifndef指令。通过条件编译提供的文本可以是 C++代码,也可以使用当前翻译单元中的其他符号使用#define定义,或者使用#undef取消定义现有符号。

#ifdef指令只是确定符号是否存在:它不测试其值。#if指令允许您测试一个表达式。您可以设置一个符号具有一个值,并根据该值编译特定的代码。表达式必须是整数,因此单个#if块可以使用#if和多个#elif指令测试多个值,并且最多一个#else

    #if TEST < 0 
       cout << "negative" << endl; 
    #elif TEST > 0 
       cout << "positive" << endl; 
    #else 
       cout << "zero or undefined" << endl; 
    #endif

如果未定义符号,则#if指令将该符号视为具有值0;如果要区分这些情况,可以使用defined运算符来测试符号是否已定义。最多只有#if/#endif块中的一个部分将被编译,如果值不匹配,则不会编译任何代码。表达式可以是宏,此时在测试条件之前将展开该宏。

有三种定义符号的方法。第一种方法是无法控制的:编译器将定义一些符号(通常带有___前缀),这些符号会提供有关编译器和编译过程的信息。其中一些符号将在后面的部分中描述。另外两种方法完全在您的控制之下-您可以在源文件(或头文件)中使用#define定义符号,也可以使用/D开关在命令行上定义它们:

    cl /EHsc prog.cpp /DTEST=1

这将使用值为1的符号TEST编译源代码。

通常,您将使用条件编译来提供不应在生产代码中使用的代码,例如,在调试模式或测试代码时使用的额外跟踪代码。例如,假设您有库代码来从数据库返回数据,但是您怀疑库函数中的 SQL 语句有错误并返回了太多的值。在这种情况下,您可能决定添加代码来记录返回的值的数量:

    vector<int> data = get_data(); 
    #if TRACE_LEVEL > 0 
    cout << "number of data items returned: " << data.size() << endl; 
    #endif

这样的跟踪消息会污染您的用户界面,并且您希望在生产代码中避免它们。但是,在调试中,它们可以帮助您确定问题发生的位置。

在调试模式下调用的任何代码,条件代码应该是const方法(这里是vector::size),也就是说,它们不应该影响任何对象或应用程序数据的状态。您必须确保您的代码在调试模式和发布模式下的逻辑完全相同。

使用 pragma

Pragma 是特定于编译器的,并且通常涉及对象文件中代码部分的技术细节。在调试代码中,有一些 Visual C++ pragma 非常有用。

一般来说,您希望您的代码尽可能少地编译警告。Visual C编译器的默认警告是/W1,这意味着只列出最严重的警告。将值逐渐增加到 2、3 或最高值 4 会增加编译过程中给出的警告数量。使用/Wall将会给出级别 4 的警告和默认情况下已禁用的警告。即使对于最简单的代码,最后一个选项也会产生一屏幕的警告。当您有数百个警告时,有用的错误消息将被隐藏在大量不重要的警告之间。由于 C标准库非常复杂,并且使用了一些几十年前的代码,编译器会对一些构造发出警告。为了防止这些警告污染构建输出,特定文件中的特定警告已被禁用。

如果您支持旧的库代码,您可能会发现代码编译时会出现警告。您可能会想要使用编译器的/W开关来降低警告级别,但这将抑制所有高于您启用的警告,并且它同样适用于您的代码和您可能包含到项目中的库代码。warning pragma 给了您更多的灵活性。有两种调用方式--您可以重置警告级别以覆盖编译器的/W开关,也可以更改特定警告的警告级别或完全禁用警告报告。

例如,在<iostream>头文件的顶部是这行:

    #pragma warning(push,3)

这表示存储当前的警告级别,并在文件的其余部分(或直到更改为止)将警告级别设置为 3。文件底部是这行:

    #pragma warning(pop)

这将恢复到先前存储的警告级别。

您还可以更改一个或多个警告的报告方式。例如,在<istream>的顶部是:

    #pragma warning(disable: 4189)

这个pragma的第一部分是指示报告警告类型(在本例中为 4189)已被禁用的disable。如果您愿意,您可以使用警告级别(1234)作为指示符来更改警告的警告级别。其中一个用途是在您正在处理的一段代码中降低警告级别,然后在代码之后将其恢复到默认级别。例如:

    #pragma warning(2: 4333) 
    unsigned shift8(unsigned char c)  
    { 
        return c >> 8;  
    } 
    #pragma warning(default: 4333)

这个函数将 char 右移 8 位,这将生成级别 1 警告 4333(右移位数过大,数据丢失)。这是一个问题,需要修复,但目前,您希望编译代码时不会收到来自此代码的警告,因此将警告级别更改为级别 2。使用默认警告级别(/W1),不会显示警告。但是,如果使用更敏感的警告级别进行编译(例如,/W2),则会报告此警告。警告级别的更改仅是临时的,因为最后一行将警告级别重置为其默认值(即 1)。在这种情况下,警告级别增加,这意味着只有在编译器上使用更敏感的警告级别时才会看到它。您还可以降低警告级别,这意味着更有可能报告警告。您甚至可以将警告级别更改为error,以便在代码中存在此类型的警告时无法编译。

添加信息性消息

在测试和调试代码时,您不可避免地会遇到一些潜在问题,但与您正在处理的问题相比,它的优先级较低。重要的是要记录问题,以便以后可以解决问题。在 Visual C++中,有两种以温和的方式记录问题的方法,还有两种会生成错误的方法。

第一种方法是添加一个TODO:注释,如下所示:

    // TODO: potential data loss, review use of shift8 function 
    unsigned shift8(unsigned char c)  
    { 
        return c >> 8;  
    }

Visual Studio 编辑器有一个名为任务列表的工具窗口。这列出了项目中以预定任务之一开头的注释(默认为TODOHACKUNDONE)。

如果任务列表窗口不可见,请通过“视图”菜单启用它。Visual Studio 2015 中的默认设置是启用 C中的任务。对于早期版本来说并非如此,但可以通过“工具”菜单、“选项”对话框,然后“文本编辑器”、“C/C”、“格式”、“查看”设置“枚举注释任务”为是来启用。任务标签列表可以在“环境”、“任务列表”项目下的“选项”对话框中找到。

任务列表列出了文件和行号的任务,您可以通过双击条目来打开文件并定位注释。

识别需要注意的代码的第二种方法是message指示。顾名思义,这只是允许您在代码中放置信息性消息。当编译器遇到此指示时,它只是将消息放在输出流中。考虑以下代码:

    #pragma message("review use of shift8 function") 
    unsigned shift8(unsigned char c)  
    { 
        return c >> 8;  
    }

如果test.cpp文件使用此代码和/W1(默认)警告级别进行编译,输出将类似于以下内容:

 Microsoft (R) C/C++ Optimizing Compiler Version 19.00.24215.1 for x86
Copyright (C) Microsoft Corporation.  All rights reserved.

test.cpp
review the use of shift8 function
test.cpp(8): warning C4333: '>>': right shift by too large amount, data loss

正如您所看到的,字符串是按照编译器看到的方式打印的,并且与警告消息相比,没有文件或行号的指示。有方法可以使用编译器符号来解决这个问题。

如果条件很重要,您会想要发出一个错误,而一种方法是使用#error指令。当编译器到达这个指令时,它会发出一个错误。这是一个严重的行为,所以只有在没有其他选择时才会使用它。您很可能会想要将其与条件编译一起使用。典型的用法是只能使用 C++编译器编译的代码:

    #ifndef __cplusplus 
    #error C++ compiler required. 
    #endif

如果使用/Tc开关编译带有此代码的文件以将代码编译为 C,则将不会定义__cplusplus预处理符号,并将生成错误。

C++11 添加了一个名为static_assert的新指令。这类似于函数调用(调用以分号结束),但它不是函数,因为它只在编译时使用。此外,该指令可以在不使用函数调用的地方使用。该指令有两个参数:一个表达式和一个字符串文字。如果表达式为false,则字符串文字将在编译时与源文件和行号一起输出,并生成错误。在最简单的级别上,您可以使用它来发出消息:

    #ifndef __cplusplus 
    static_assert(false, "Compile with /TP"); 
    #endif 
    #include <iostream> // needs the C++ compiler

由于第一个参数是false,指令将在编译期间发出错误消息。使用#error指令也可以实现相同的效果。<type_traits>库具有用于测试类型属性的各种谓词。例如,is_class模板类具有一个简单的模板参数,即类型,如果该类型是一个class,则static成员value设置为true。如果您有一个应该只对类进行实例化的模板化函数,您可以添加这个static_assert

    #include <type_traits> 

    template <class T> 
    void func(T& value) 
    { 
        static_assert(std::is_class<T>::value, "T must be a class"); 
        // other code 
    }

在编译时,编译器将尝试实例化函数,并使用value在该类型上实例化is_class,以确定编译是否应该继续。例如,以下代码:

    func(string("hello")); 
    func("hello");

第一行将正确编译,因为编译器将实例化一个函数func<string>,参数是一个class。然而,第二行将无法编译,因为实例化的函数是func<const char*>,而const char*不是一个class。输出是:

Microsoft (R) C/C++ Optimizing Compiler Version 19.00.24215.1 for x86
Copyright (C) Microsoft Corporation.  All rights reserved.

test.cpp
test.cpp(25): error C2338: T must be a class
test.cpp(39): note: see reference to function template instantiation 

'void func<const char*>(T)' being compiled
with
[
 T=const char *
]

static_assert第 25 行,因此会生成错误,即T 必须是一个 class第 39 行是对func<const char*>的第一次调用,并为错误提供了上下文。

调试的编译器开关

为了让调试器能够逐步执行程序,您必须提供信息,以便调试器将机器代码与源代码关联起来。至少,这意味着关闭所有优化,因为在尝试优化代码时,C编译器会重新排列代码。优化默认关闭(因此使用/Od开关是多余的),但显然,为了能够调试进程并逐步执行 C代码,您需要删除所有/O优化开关。

由于 C++标准库使用 C 运行时,您需要编译您的代码以使用后者的调试版本。您使用的开关取决于您是构建进程还是动态链接库DLL),以及您是静态链接 C 运行时还是通过 DLL 访问它。如果您正在编译一个进程,您可以使用/MDd来获取 DLL 中 C 运行时的调试版本,如果您使用/MTd,您将获得静态链接 C 运行时的调试版本。如果您正在编写一个动态链接库,您必须使用/LDd,并且还要使用一个 C 运行时开关(/MTd是默认值)。这些开关将定义一个名为_DEBUG的预处理器符号。

调试器需要知道调试器符号信息--变量的名称和类型,函数的名称和与代码相关联的行号。通过名为程序数据库的文件来完成这一点,其扩展名为pdb。您可以使用/Z开关之一来生成pdb文件:/Zi/ZI开关将创建两个文件,一个文件名以VC开头(例如VC140.pdb),其中包含所有obj文件的调试信息,另一个文件名为项目名称,其中包含进程的调试信息。如果您编译而不链接(/c),则只会创建第一个文件。Visual C项目向导默认使用/Od /MDd /ZI来进行调试构建。/ZI开关意味着以一种允许 Visual C调试器执行EditContinue的格式创建程序数据库,也就是说,您可以更改一些代码并继续逐步执行代码,而无需重新编译。当您为发布构建编译时,向导将使用/O2 /MD /Zi开关,这意味着代码经过了速度优化,但仍将创建一个程序数据库(不支持EditContinue)。代码不需要程序数据库来运行(实际上,您不应该将其与代码一起分发),但如果您有崩溃报告并需要在调试器下运行发布构建代码,它将非常有用。

这些/Z编译器开关假定链接器使用/debug开关运行(如果编译器调用链接器,它将传递此开关)。链接器将从VC程序数据库文件中的调试信息创建项目程序数据库。

这引发了一个问题,即为什么发布构建文件需要一个程序数据库。如果在调试器下运行程序并查看调用堆栈,通常会看到操作系统文件中的一长串堆栈帧。这些通常具有由 DLL 名称和一些数字和字符组成的相当无意义的名称。可以安装 Windows 的符号(pdb文件),或者如果它们未安装,则指示 Visual C++调试器从网络上的计算机上下载正在使用的库的符号,称为符号服务器。这些符号不是库的源代码,但它们确实为您提供了函数的名称和参数的类型,这为您提供了有关在您单步执行时调用堆栈状态的附加信息。

预处理器符号

要在代码中使用跟踪、断言和报告功能,必须启用调试运行时库,方法是使用/MDd/MTd/LDd编译器开关,这将定义_DEBUG预处理器符号。_DEBUG预处理器符号启用了许多功能,反之,不定义此符号将有助于优化代码。

    #ifdef _DEBUG 
       cout << "debug build" << endl; 
    #else 
       cout << "release built" << endl; 
    #endif

C++编译器还将通过一些标准预处理器符号提供信息。其中大多数仅对库编写者有用,但也有一些可能会用到。

ANSI 标准规定,当编译器编译代码为 C++(而不是 C)时,应定义__cplusplus符号,并且还指定__FILE__符号应包含文件名,__LINE__符号将在访问它的地方具有行号。__func__符号将具有当前函数名称。这意味着您可以创建以下跟踪代码:

    #ifdef _DEBUG 
    #define TRACE cout << __func__ << " (" << __LINE__ << ")" << endl; 
    #else 
    #define TRACE 
    #endif

如果此代码编译用于调试(例如,/MTd),则cout行将在使用TRACE时内联;如果代码未编译用于调试,则TRACE将不起作用。__func__符号只是函数名称,它没有限定,因此如果在类方法中使用它,它将不提供有关类的任何信息。

Visual C还定义了 Microsoft 特定的符号。__FUNCSIG__符号提供完整的签名,包括类名(和任何namespace名称)、返回类型和参数。如果只想要完全限定的名称,那么可以使用__FUNCTION__符号。在 Windows 头文件中经常看到的一个符号是_MSC_VER。它具有当前 C编译器的版本号,并且与条件编译一起使用,以便只有支持它们的编译器才会编译新的语言特性。

Visual C++项目页面定义了类似$(ProjectDir)$(Configuration)构建宏。这些仅由 MSBuild 工具使用,因此在编译过程中源文件中并不自动可用,但是,如果将预处理器符号设置为构建宏的值,则该值将在编译时通过该符号可用。系统环境变量也可用作构建宏,因此可以使用它们来影响构建。例如,在 Windows 上,系统环境变量USERNAME具有当前登录用户的名称,因此可以使用它来设置一个符号,然后在编译时访问它。

在 Visual C项目页面上,您可以在 C/C预处理器项目页面上添加一个名为预处理器定义的定义:

    DEVELOPER="$(USERNAME)"

然后,在您的代码中,您可以添加一行使用此符号:

    cout << "Compiled by " << DEVELOPER << endl;

如果你正在使用一个 make 文件,或者只是从命令行调用cl,你可以添加一个开关来定义这个符号,就像这样:

    /DDEVELOPER="$(USERNAME)"

在这里转义双引号很重要,因为没有它们,引号会被编译器吞掉。

之前,你看到了#pragma message#error指令如何用于将消息放入编译器的输出流中。在 Visual Studio 中编译代码时,编译器和链接器的输出将显示在输出窗口中。如果消息的形式是:

    path_to_source_file(line) message

其中path_to_source_file是文件的完整路径,linemessage出现的行号。然后,当你在输出窗口中双击这一行时,文件将被加载(如果尚未加载),并且插入点将放在该行上。

__FILE____LINE__符号为你提供了使#pragma message#error指令更有用所需的信息。输出__FILE__很简单,因为它是一个字符串,C++会连接字符串字面量:

    #define AT_FILE(msg) __FILE__ " " msg 

    #pragma message(AT_FILE("this is a message"))

该宏作为#pragma的一部分被调用以正确格式化消息;然而,你不能从宏中调用#pragma,因为#有一个特殊的目的(稍后将会用到)。这段代码的结果将类似于:

    c:\Beginning_C++Chapter_10test.cpp this is a message

通过宏输出__LINE__需要更多的工作,因为它保存了一个数字。这个问题在 C 语言中很常见,所以有一个标准的解决方案,使用两个宏和字符串操作符#

    #define STRING2(x) #x 
    #define STRING(x) STRING2(x) 
    #define AT_FILE(msg) __FILE__ "(" STRING(__LINE__) ") " msg

STRING宏用于将__LINE__符号扩展为一个数字,STRING2宏用于将数字转换为字符串。AT_FILE宏以正确的格式格式化整个字符串。

生成诊断消息

诊断消息的有效使用是一个广泛的话题,所以本节只会给出基础知识。在设计代码时,你应该让编写诊断消息变得容易,例如,提供机制来转储对象的内容,并提供访问测试类不变量和前后条件的代码。你还应该分析代码,确保适当的消息被记录。例如,在循环中发出诊断消息通常会填满日志文件,使得难以阅读日志文件中的其他消息。然而,循环中一直出现失败可能本身就是一个重要的诊断,尝试执行失败操作的次数也可能是一个重要的诊断,因此你可能希望记录下来。

使用cout输出诊断消息的优点是将这些消息与用户输出集成在一起,这样你可以看到中间结果的最终效果。缺点是诊断消息与用户输出集成在一起,而且通常会有大量的诊断消息,这些消息会完全淹没程序的用户输出。

C++有两个流对象,你可以使用它们来代替coutclogcerr流对象将字符数据写入标准错误流(C 流指针stderr),通常会显示在控制台上,就像使用cout一样(它输出到标准输出流,C 流指针stdout),但你可以将其重定向到其他地方。clogcerr之间的区别在于clog使用缓冲输出,这可能比未缓冲的cerr性能更好。然而,如果应用程序意外停止而没有刷新缓冲区,数据可能会丢失。

由于clogcerr流对象在发布版本和调试版本中都可用,所以你应该只用它们来处理你的最终用户会看到的消息。这使它们不适合用于跟踪消息(稍后将介绍)。相反,你应该用它们来处理用户能够解决的诊断消息(也许找不到文件或者进程没有执行操作的安全访问权限)。

    ofstream file; 
    if (!file.open(argv[1], ios::out)) 
    { 
        clog << "cannot open " << argv[1] << endl; 
        return 1; 
    }

此代码以两个步骤打开文件(而不是使用构造函数),如果文件无法打开,则open方法将返回false。代码检查是否成功打开文件,如果失败,它将通过clog对象告知用户,然后从包含代码的任何函数返回,因为file对象现在无效且无法使用。clog对象是有缓冲的,但在这种情况下,我们希望立即通知用户,这是由endl操作器执行的,它在流中插入换行然后刷新流。

默认情况下,clogcerr流对象将输出到标准错误流,这意味着对于控制台应用程序,您可以通过重定向流来分离输出流和错误流。在命令行上,可以使用stdin的值为 0,stdout的值为 1,stderr的值为 2 以及重定向操作符>来重定向标准流。例如,一个名为app.exe的应用程序可以在main函数中包含以下代码:

    clog << "clog" << endl; 
    cerr << "cerrn"; 
    cout << "cout" << endl;

cerr对象没有缓冲,因此无论您使用n还是endl来换行都无关紧要。当您在命令行上运行时,您会看到类似以下的内容:

C:\Beginning_C++\Chapter_10>app
clog
cerr
cout

要将流重定向到文件,请将流句柄(stdout为 1,stderr为 2)重定向到文件;控制台将打开文件并将流写入文件:

C:\Beginning_C++\Chapter_10>app 2>log.txt
cout

C:\Beginning_C++\Chapter_10>type log.txt
clog
cerr

正如上一章所示,C++流对象是分层的,因此向流中插入数据的调用将根据流的类型将数据写入底层流对象,有或没有缓冲。可以使用rdbuf方法获取和替换此流缓冲区对象。如果要将clog对象重定向到应用程序的文件中,可以编写以下代码:

    extern void run_code(); 

    int main() 
    { 
        ofstream log_file; 
        if (log_file.open("log.txt")) clog.rdbuf(log_file.rdbuf()); 

        run_code(); 

        clog.flush(); 
        log_file.close(); 
        clog.rdbuf(nullptr); 
        return 0; 
    }

在此代码中,应用程序代码将位于run_code函数中,其余代码设置了clog对象以重定向到文件。

请注意,当run_code函数返回时(应用程序已完成),文件将被显式关闭;这并不完全必要,因为ofstream析构函数将关闭文件,并且在这种情况下,当main函数返回时将会发生这种情况。最后一行很重要。标准流对象在调用main函数之前创建,并且它们将在main函数返回后的某个时候被销毁,也就是说,在文件对象被销毁之后。为了防止clog对象访问已销毁的文件对象,将调用rdbuf方法并传递nullptr以指示没有缓冲区。

使用 C 运行时的跟踪消息

通常,您会希望通过实时运行应用程序并输出跟踪消息来测试您的代码,以测试您的算法是否有效。有时,您会希望测试函数的调用顺序(例如,在switch语句或if语句中正确分支的发生),在其他情况下,您会希望测试中间值,以确保输入数据正确并且对该数据的计算正确。

跟踪消息可能会产生大量数据,因此将这些消息发送到控制台是不明智的。非常重要的是,跟踪消息只在调试构建中生成。如果在产品代码中保留跟踪消息,可能会严重影响应用程序的性能(稍后将进行解释)。此外,跟踪消息不太可能被本地化,也不会被检查以查看它们是否包含可用于反向工程您的算法的信息。在发布构建中跟踪消息的另一个问题是,您的客户会认为您正在提供尚未完全测试的代码。因此,非常重要的是,只有在调试构建中生成跟踪消息时才定义_DEBUG符号。

C 运行时提供了一系列以_RPT开头的宏,可以在定义_DEBUG时用于跟踪消息。这些宏有char和宽字符版本,并且有一些版本仅报告跟踪消息,还有一些版本将报告消息和消息的位置(源文件和行号)。最终,这些宏将调用一个名为_CrtDbgReport的函数,该函数将使用在其他地方确定的设置生成消息。

_RPTn宏(其中n012345)将采用格式字符串和 0 到 5 个参数,这些参数将在报告之前放入字符串中。宏的第一个参数表示要报告的消息类型:_CRT_WARN_CRT_ERROR_CRT_ASSERT。这些类别中的最后两个是相同的,并且指的是断言,这将在后面的部分中介绍。报告宏的第二个参数是格式字符串,然后是所需数量的参数。_RPTFn宏的格式相同,但还将报告源文件和行号以及格式化的消息。

默认操作是,_CRT_WARN消息不会产生任何输出,而_CRT_ERROR_CRT_ASSERT消息将生成一个弹出窗口,允许您中止或调试应用程序。您可以通过调用_CrtSetReportMode函数并提供类别和指示采取的操作的值来更改对这些消息类别中的任何一个的响应。如果使用_CRTDBG_MODE_DEBUG,则消息将被写入调试器输出窗口。如果使用_CRTDBG_MODE_FILE,则消息将被写入一个文件,您可以打开并将句柄传递给_CrtSetReportFile函数。(您还可以使用_CRTDBG_FILE_STDERR_CRTDBG_FILE_STDOUT作为文件句柄,将消息发送到标准输出或错误输出。)如果将_CRTDBG_MODE_WNDW用作报告模式,则消息将使用中止/重试/忽略对话框显示。由于这将暂停当前执行线程,因此应仅用于断言消息(默认操作):

    include <crtdbg.h> 

    extern void run_code(); 

    int main() 
    { 
        _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_DEBUG); 
        _RPTF0(_CRT_WARN, "Application startedn"); 

        run_code(); 

        _RPTF0(_CRT_WARN, "Application endedn"); 
        return 0; 
    }

如果在消息中不提供n,则下一条消息将附加到您的消息末尾,在大多数情况下,这不是您想要的(尽管您可以为对_RPTn宏的一系列调用辩解,其中最后一个以n终止)。

在编译项目时,Visual Studio 输出窗口会显示(要在调试时显示,请在“视图”菜单中选择“输出”选项),在顶部是一个名为“显示来自输出”的组合框,通常设置为“生成”。如果将其设置为“调试”,则将在调试会话期间看到生成的调试消息。这些消息将包括有关加载调试符号的消息以及从_RPTn宏重定向到输出窗口的消息。

如果希望将消息定向到文件,则需要使用 Win32 的CreateFile函数打开文件,并在调用_CrtSetReportFile函数时使用该函数的句柄。为此,您需要包含 Windows 头文件:

    #define WIN32_LEAN_AND_MEAN 
    #include <Windows.h> 
    #include <crtdbg.h>

WIN32_LEAN_AND_MEAN宏将减小包含的 Windows 文件的大小。

    HANDLE file =  
       CreateFileA("log.txt", GENERIC_WRITE, 0, 0, CREATE_ALWAYS, 0, 0); 
    _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE); 
    _CrtSetReportFile(_CRT_WARN, file); 
    _RPTF0(_CRT_WARN, "Application startedn"); 

    run_code(); 

    _RPTF0(_CRT_WARN, "Application endedn"); 
    CloseHandle(file);

此代码将将警告消息定向到名为log.txt的文本文件,每次运行应用程序时都会创建新文件。

使用 Windows 跟踪消息

OutputDebugString函数用于向调试器发送消息。该函数通过名为DBWIN_BUFFER共享内存部分执行此操作。共享内存意味着任何进程都可以访问此内存,因此 Windows 提供了两个称为DBWIN_BUFFER_READYDBWIN_DATA_READY事件对象,用于控制对此内存的读取和写入访问。这些事件对象在进程之间共享,并且可以处于已发信号或未发信号状态。调试器将通过发信号DBWIN_BUFFER_READY事件来指示它不再使用共享内存,此时OutputDebugString函数可以将数据写入共享内存。调试器将等待DBWIN_DATA_READY事件,当OutputDebugString函数完成写入内存并且可以安全读取缓冲区时,将发出该事件。写入内存部分的数据将是调用OutputDebugString函数的进程 ID,后跟长达 4 KB 的数据字符串。

问题在于当您调用OutputDebugString函数时,它将等待DBWIN_BUFFER_READY事件,这意味着当您使用此函数时,您将应用程序的性能与另一个进程的性能(通常是调试器,但也可能不是)耦合在一起。很容易编写一个进程来访问DBWIN_BUFFER共享内存部分并访问相关的事件对象,因此可能会出现您的生产代码在运行具有此类应用程序的机器上。因此,非常重要的是您使用条件编译,以便OutputDebugString函数仅在调试构建中使用--这些代码永远不会发布给您的客户:

    extern void run_code(); 

    int main() 
    { 
        #ifdef _DEBUG 
            OutputDebugStringA("Application startedn"); 
        #endif 

        run_code(); 

        #ifdef _DEBUG 
           OutputDebugStringA("Application endedn"); 
        #endif 
        return 0; 
    }

您需要包含windows.h头文件才能编译此代码。至于_RPT示例,您将需要在调试器下运行此代码以查看输出,或者运行类似于DebugView(可从微软的 Technet 网站获取)的应用程序。

Windows 提供了DBWinMutex互斥对象,用作访问此共享内存和事件对象的整体关键。顾名思义,当您拥有互斥体的句柄时,您将对资源具有互斥访问权限。问题在于,进程不必拥有此互斥体的句柄即可使用这些资源,因此您无法保证,如果您的应用程序认为它具有独占访问权限,它确实具有独占访问权限。

使用断言

断言检查条件是否为真。断言的意思就是:如果条件不为真,则程序不应继续。显然,断言不应在发布代码中调用,因此必须使用条件编译。断言应用于检查永远不应发生的条件:永远不会发生的事件。由于条件不会发生,因此在发布构建中不需要断言。

C 运行时提供了assert宏,可通过<cassert>头文件使用。该宏及其作为唯一参数传递的表达式中调用的任何函数,除非定义了NDEBUG符号,否则将被调用。也就是说,您不必定义_DEBUG符号来使用断言,并且应该采取额外的措施来明确阻止调用assert

值得再次强调。即使未定义_DEBUGassert宏也已定义,因此断言可能会在发布代码中调用。为防止这种情况发生,您必须在发布构建中定义NDEBUG符号。相反,您可以在调试构建中定义NDEBUG符号,以便可以使用跟踪,但不必使用断言。

通常,您将在调试构建中使用断言来检查函数中是否满足前置条件和后置条件,以及类不变条件是否得到满足。例如,您可能有一个二进制缓冲区,在第十个字节位置有一个特殊值,因此编写了一个提取该字节的函数:

    const int MAGIC=9; 

    char get_data(char *p, size_t size) 
    { 
        assert((p != nullptr)); 
        assert((size >= MAGIC)); 
        return p[MAGIC]; 
    }

在这里,对assert的调用用于检查指针不是nullptr并且缓冲区足够大。如果这些断言为真,则意味着可以通过指针安全地访问第十个字节。

虽然在这段代码中并不是严格必要的,但是断言表达式是用括号括起来的。养成这样做的习惯是很好的,因为assert是一个宏,因此表达式中的逗号将被视为宏参数分隔符;括号可以防止这种情况发生。

由于assert宏在默认情况下将在发布构建中定义,因此您将需要通过在编译器命令行上定义NDEBUG来禁用它们,在您的 make 文件中,或者您可能希望显式使用条件编译:

    #ifndef _DEBUG 
    #define NDEBUG 
    #endif

如果调用断言并且失败,则会在控制台上打印断言消息以及源文件和行号信息,然后通过调用abort终止进程。如果进程是使用发布构建标准库构建的,则进程abort是直接的,但是如果使用调试构建,则用户将看到标准的中止/重试/忽略消息框,其中中止和忽略选项将中止进程。重试选项将使用即时JIT)调试将注册的调试器附加到进程。

相比之下,只有在定义了_DEBUG时,_ASSERT_ASSERTE宏才会被定义,因此这些宏在发布构建中将不可用。当表达式为false时,这两个宏都会生成一个断言消息。_ASSERT宏的消息将包括源文件和行号以及一个说明断言失败的消息。_ASSERTE宏的消息类似,但包括失败的表达式。

    _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE); 
    _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDOUT); 

    int i = 99; 
    _ASSERTE((i > 100));

此代码设置了报告模式,以便失败的断言将作为消息打印在控制台上(而不是默认的中止/重试/忽略对话框)。由于变量显然小于 100,断言将失败,因此进程将终止,并在控制台上打印以下消息:

    test.cpp(23) : Assertion failed: (i > 100)

中止/重试/忽略对话框为测试应用程序的人提供了将调试器附加到进程的选项。如果您决定断言的失败是可恶的,您可以通过调用_CrtDbgBreak强制调试器附加到进程。

    int i = 99; 
    if (i <= 100) _CrtDbgBreak();

您不需要使用条件编译,因为在发布构建中,_CrtDbgBreak函数是无操作的。在调试构建中,此代码将触发 JIT 调试,从而使您可以关闭应用程序或启动调试器,如果选择后者,则将启动注册的 JIT 调试器。

应用程序终止

main函数是应用程序的入口点。但是,操作系统不会直接调用它,因为 C会在调用main之前执行初始化。这包括构造标准库全局对象(cincoutcerrclog和宽字符版本),以及为支持 C库的 C 运行时库执行的一系列初始化。此外,还有您的代码创建的全局和静态对象。当main函数返回时,将必须调用全局和静态对象的析构函数,并在 C 运行时上执行清理。

有几种方法可以有意终止进程。最简单的方法是从main函数返回,但这假设从代码想要完成进程的地方到main函数有一个简单的路径返回。当然,进程终止必须是有序的,您应该避免编写在代码的任何地方正常停止进程的代码。但是,如果您遇到数据损坏且无法恢复的情况,并且任何其他操作都可能损坏更多数据,那么您可能别无选择,只能终止应用程序。

<cstdlib>头文件提供了访问允许您终止和处理应用程序终止的函数的头文件。当一个 C程序正常关闭时,C基础设施将调用在main函数中创建的对象的析构函数(按照它们的构造顺序的相反顺序)和static对象的析构函数(可能是在main函数之外的函数中创建的)。atexit函数允许您注册在main函数完成和调用static对象析构函数之后将被调用的没有参数和返回值的函数。您可以通过多次调用此函数注册多个函数,并且在终止时,这些函数将按照它们的注册顺序的相反顺序被调用。在调用atexit函数注册的函数之后,将调用任何全局对象的析构函数。

还有一个名为_onexit的 Microsoft 函数,它也允许您注册在正常终止期间要调用的函数。

exit_exit函数执行进程的正常退出,也就是在关闭进程之前清理 C 运行时并刷新任何打开的文件。exit函数通过调用任何注册的终止函数来执行额外的工作;_exit函数不调用这些终止函数,因此是一个快速退出。这些函数不会调用临时或自动对象的析构函数,因此如果您使用堆栈对象来管理资源,您必须在调用exit之前显式调用析构函数代码。但是,静态和全局对象的析构函数将被调用。

quick_exit函数导致正常关闭,但不调用任何析构函数,也不刷新任何流,因此没有资源清理。使用atexit注册的函数不会被调用,但您可以通过使用at_quick_exit函数注册终止函数来调用这些终止函数。在调用这些终止函数之后,quick_exit函数调用_Exit函数关闭进程。

您还可以调用terminate函数来关闭一个没有清理的进程。这个进程将调用一个已经注册了set_terminate函数的函数,然后调用abort函数。如果程序中发生异常并且没有被捕获,因此传播到main函数,C++基础设施将调用terminate函数。abort函数是终止进程的最严重的机制。这个函数将在不调用对象的析构函数或执行任何其他清理的情况下退出进程。该函数会引发SIGABORT信号,因此可以使用signal函数注册一个函数,在进程终止之前调用该函数。

错误值

有些函数设计为执行一个动作并根据该动作返回一个值,例如,sqrt将返回一个数的平方根。其他函数执行更复杂的操作,并使用返回值来指示函数是否成功。关于这种错误值没有共同的约定,因此如果一个函数返回一个简单的整数,就不能保证一个库使用的值与另一个库中的函数返回的值具有相同的含义。这意味着您必须仔细查看您使用的任何库代码的文档。

Windows 提供了常见的错误值,可以在winerror.h头文件中找到,Windows 的软件开发工具包SDK)中的函数只返回该文件中的值。如果您编写的库代码将专门用于 Windows 应用程序,考虑使用该文件中的错误值,因为您可以使用 Win32 的FormatMessage函数来获取错误的描述,如下一节所述。

C 运行时库提供了一个名为errno的全局变量(实际上它是一个可以视为变量的宏)。C 函数将返回一个值来指示它们失败了,您可以访问errno值来确定错误是什么。<errno.h>头文件定义了标准的 POSIX 错误值。errno变量不表示成功,它只表示错误,因此只有在函数指示存在错误时才应该访问它。strerror函数将返回一个包含您传递的错误值描述的 C 字符串;这些消息根据通过调用setlocale函数设置的当前 C 语言环境进行本地化。

获取消息描述

要在运行时获取 Win32 错误代码的描述,您可以使用 Win32FormatMessage函数。这将获取系统消息或自定义消息的描述(在下一节中描述)。如果要使用自定义消息,则必须加载具有绑定到其上的消息资源的可执行文件(或 DLL),并将HMODULE句柄传递给FormatMessage函数。如果要获取系统消息的描述,则无需加载模块,因为 Windows 会为您执行此操作。例如,如果调用 Win32CreateFile函数打开一个文件,但找不到该文件,函数将返回一个“INVALID_HANDLE_VALUE”的值,表示存在错误。要获取错误的详细信息,您可以调用GetLastError函数(它返回一个 32 位无符号值,有时称为DWORDHRESULT)。然后,您可以将错误值传递给FormatMessage

    HANDLE file = CreateFileA( 
        "does_not_exist", GENERIC_READ, 0, 0, OPEN_EXISTING, 0, 0); 
    if (INVALID_HANDLE_VALUE == file) 
    { 
        DWORD err = GetLastError(); 
        char *str; 
        DWORD ret = FormatMessageA( 
            FORMAT_MESSAGE_FROM_SYSTEM|FORMAT_MESSAGE_ALLOCATE_BUFFER, 
            0, err, LANG_USER_DEFAULT, reinterpret_cast<LPSTR>(&str),  
            0, 0); 
        cout << "Error: "<< str << endl; 
        LocalFree(str); 
    } 
    else 
    { 
        CloseHandle(file); 
    }

此代码尝试打开一个不存在的文件,并获取与失败相关的错误值(这将是一个ERROR_FILE_NOT_FOUND值)。然后,代码调用FormatMessage函数获取描述错误的字符串。函数的第一个参数是一个标志,指示函数应该如何工作;在这种情况下,FORMAT_MESSAGE_FROM_SYSTEM标志表示错误是系统错误,FORMAT_MESSAGE_ALLOCATE_BUFFER标志表示函数应该使用 Win32LocalAlloc函数分配足够大的缓冲区来容纳字符串。

如果错误是您定义的自定义值,则应使用FORMAT_MESSAGE_FROM_HMODULE标志,使用LoadLibrary打开文件,并将结果的HMODULE作为通过第二个参数传递的参数。

第三个参数是错误消息编号(来自GetLastError),第四个是指示要使用的语言 ID 的LANGID(在这种情况下,使用LANG_USER_DEFAULT来获取当前登录用户的语言 ID)。 FormatMessage函数将为错误值生成一个格式化的字符串,这个字符串可能有替换参数。格式化的字符串返回到一个缓冲区中,您有两个选项:您可以分配一个字符缓冲区,并将指针作为第五个参数传递,并将长度作为第六个参数传递,或者您可以请求函数使用LocalAlloc函数分配一个缓冲区,就像这个例子中一样。要访问函数分配的缓冲区,您需要通过第五个参数传递指针变量的地址

请注意,第五个参数用于接受指向用户分配的缓冲区的指针,或者返回系统分配的缓冲区的地址,这就是为什么在这种情况下必须对指针进行转换。

一些格式字符串可能有参数,如果有,这些值将通过第七个参数中的数组传递(在这种情况下,没有传递数组)。前面代码的结果是字符串:

    Error: The system cannot find the file specified.

使用消息编译器、资源文件和FormatMessage,您可以提供一种机制,从您的函数中返回错误值,然后根据当前语言环境将其转换为本地化字符串。

使用消息编译器

前面的示例表明,您可以获取 Win32 错误的本地化字符串,但也可以创建自己的错误并提供绑定为资源的本地化字符串到您的进程或库。如果您打算向最终用户报告错误,您必须确保描述已本地化。Windows 提供了一个名为消息编译器(mc.exe)的工具,它将获取包含各种语言中消息条目的文本文件,并将它们编译为可以绑定到模块的二进制资源。

例如:

    LanguageNames = (British = 0x0409:MSG00409) 
    LanguageNames = (French  = 0x040c:MSG0040C) 

    MessageId       = 1 
    SymbolicName    = IDS_GREETING 
    Language        = English 
    Hello 
    . 
    Language        = British 
    Good day 
    . 
    Language        = French 
    Salut 
    .

这为同一消息定义了三个本地化字符串。这里的消息是简单的字符串,但您可以定义带有占位符的格式消息,这些占位符可以在运行时提供。中性语言是美国英语,此外我们还为英国英语和法语定义了字符串。语言的名称在文件顶部的LanguageNames行中定义。这些条目具有稍后在文件中使用的名称,语言的代码页以及将包含消息资源的二进制资源的名称。

MessageIdFormatMessage函数将使用的标识符,SymbolicName是一个预处理器符号,将在头文件中定义,以便您可以在 C++代码中使用此消息而不是数字。通过将其传递给命令行实用程序mc.exe来编译此文件,将创建五个文件:一个具有符号定义的头文件,三个二进制源(MSG00001.bin,默认情况下为中性语言创建,MSG00409.binMSG0040C.bin,由于LanguageNames行而创建),以及资源编译器文件。对于此示例,资源编译器文件(扩展名为.rc)将包含:

    LANGUAGE 0xc,0x1 
    1 11 "MSG0040C.bin" 
    LANGUAGE 0x9,0x1 
    1 11 "MSG00001.bin" 
    LANGUAGE 0x9,0x1 
    1 11 "MSG00409.bin"

这是一个标准的资源文件,可以由 Windows SDK 资源编译器(rc.exe)编译,该编译器将消息资源编译为.res文件,该文件可以绑定到可执行文件或 DLL。具有绑定到其的类型11资源的进程或 DLL 可以被FormatMessage函数用作描述性错误字符串的来源。

通常,您不会使用消息 ID 1,因为它不太可能是唯一的,而且您可能希望利用facility codeseverity code(有关 facility code 的详细信息,请查看winerror.h头文件)。此外,为了指示消息不是 Windows,您可以在运行mc.exe时使用/c开关设置错误代码的客户位。这意味着您的错误代码将不是像 1 这样的简单值,但这应该无所谓,因为您的代码将使用头文件中定义的符号。

C++异常

异常是指异常情况。它们不是正常情况。它们不是你想要发生的情况,但可能会发生的情况。任何异常情况通常意味着您的数据将处于不一致的状态,因此使用异常意味着您需要以事务性术语思考,即,操作要么成功,要么对象的状态应保持与尝试操作之前相同。当代码块中发生异常时,代码块中发生的所有事情都将无效。如果代码块是更广泛代码块的一部分(比如,一个由另一个函数调用的一系列函数调用的函数),那么另一个代码块中的工作将无效。这意味着异常可能传播到调用堆栈上游的其他代码块,使依赖于操作成功的对象无效。在某个时候,异常情况将是可恢复的,因此您将希望防止异常进一步传播。

异常规范

异常规范在 C11 中已被弃用,但您可能会在早期的代码中看到它们。规范是通过应用于函数声明的throw表达式来给出可以从函数中抛出的异常。throw规范可以是省略号,这意味着函数可以抛出异常,但类型未指定。如果规范为空,则表示函数不会抛出异常,这与在 C11 中使用noexcept指定符相同。

noexcept指定符告诉编译器不需要异常处理,因此如果函数中发生异常,异常将不会从函数中冒出,并且将立即调用terminate函数。在这种情况下,不能保证自动对象的析构函数被调用。

C++异常语法

在 C++中,通过抛出异常对象生成异常情况。该异常对象可以是任何您喜欢的东西:对象、指针或内置类型,但由于异常可能由其他人编写的代码处理,最好标准化用于表示异常的对象。为此,标准库提供了exception类,它可以用作基类。

    double reciprocal(double d) 
    { 
        if (d == 0)  
        { 
            // throw 0; 
            // throw "divide by zero"; 
            // throw new exception("divide by zero"); 
            throw exception("divide by zero"); 
        } 
        return 1.0 / d; 
    }

此代码测试参数,如果为零,则引发异常。给出了四个示例,所有示例都是有效的 C++,但只有最后一个版本是可接受的,因为它使用了一个标准库类(或从标准库类派生的类),并且遵循了异常通过值抛出的约定。

当引发异常时,异常处理基础设施接管。执行将在当前代码块中停止,并且异常将向上传播到调用堆栈。当异常通过代码块传播时,所有自动对象都将被销毁,但在代码块中在堆上创建的对象将不会被销毁。这是一个称为堆栈展开的过程,即在异常移动到调用堆栈中的上面的堆栈帧之前,尽可能清理每个堆栈帧。如果异常没有被捕获,它将传播到main函数,此时将调用terminate函数来处理异常(因此将终止进程)。

您可以保护代码以处理传播的异常。代码受到try块的保护,并且通过相关的catch块捕获:

    try  
    { 
        string s("this is an object"); 
        vector<int> v = { 1, 0, -1}; 
        reciprocal(v[0]); 
        reciprocal(v[1]); 
        reciprocal(v[2]); 
    } 
    catch(exception& e) 
    { 
        cout << e.what() << endl; 
    }

与 C++中的其他代码块不同,即使trycatch块只包含单行代码,括号也是必需的。在前面的代码中,对reciprocal函数的第二次调用将引发异常。异常将停止代码块中的任何更多代码的执行,因此不会发生对reciprocal函数的第三次调用。相反,异常会传播出代码块。try块是大括号之间定义的对象的作用域,这意味着这些对象的析构函数将被调用(sv)。然后控制传递到相关的catch块,在这种情况下,只有一个处理程序。catch块是try块的一个单独的块,因此您无法访问在try块中定义的任何变量。这是有道理的,因为当生成异常时,整个代码块都是被污染的,因此您不能信任在该块中创建的任何对象。此代码使用了被接受的约定,即异常被引用捕获,以便捕获实际的异常对象,而不是副本。

约定是:抛出我的值,通过引用捕获。

标准库提供了一个名为uncaught_exception的函数,如果已经抛出异常但尚未处理,则返回true。测试这一点似乎有些奇怪,因为当异常发生时,除了异常基础设施之外不会调用任何代码(例如catch处理程序),你应该在那里放置异常代码。然而,当异常被抛出时确实会调用其他代码:在堆栈清除期间被销毁的自动对象的析构函数。uncaught_exception函数应该在析构函数中使用,以确定对象是否由于异常而被销毁,而不是由于对象超出范围或被删除而进行正常对象销毁。例如:

    class test 
    { 
        string str; 
    public: 
        test() : str("") {} 
        test(const string& s) : str(s) {} 
        ~test() 
        { 
            cout << boolalpha << str << " uncaught exception = " 
             << uncaught_exception() << endl; 
        } 
    };

这个简单的对象指示它是否因异常堆栈展开而被销毁。可以像这样进行测试:

    void f(bool b) 
    { 
        test t("auto f"); 
        cout << (b ? "f throwing exception" : "f running fine")  
            << endl; 
        if (b) throw exception("f failed"); 
    } 

    int main() 
    { 
        test t1("auto main"); 
        try 
        { 
            test t2("in try in main"); 
            f(false); 
            f(true); 
            cout << "this will never be printed"; 
        } 
        catch (exception& e) 
        { 
            cout << e.what() << endl; 
        } 
        return 0; 
    }

f函数只有在使用true值调用时才会抛出异常。main函数调用f两次,一次使用false值(所以在f中不会抛出异常),第二次使用true。输出为:

 f running fine
 auto f uncaught exception = false
 f throwing exception
 auto f uncaught exception = true
 in try in main uncaught exception = true
 f failed
 auto main uncaught exception = false

第一次调用f时,test对象被正常销毁,所以uncaught_exception将返回false。第二次调用f时,函数中的test对象在异常被捕获之前被销毁,所以uncaught_exception将返回true。由于抛出了异常,执行离开try块,所以try块中的test对象被销毁,uncaught_exception将返回true。最后,当异常被处理并且控制返回到catch块后的代码时,main函数中堆栈上创建的test对象将在main函数返回时被销毁,所以uncaught_exception将返回false

标准异常类

exception类是一个简单的 C 字符串容器:字符串作为构造函数参数传递,并通过what访问器可用。标准库在<exception>库中声明了异常类,并鼓励您从中派生自己的异常类。标准库提供了以下派生类;大多数在<stdexcept>中定义。

抛出
bad_alloc new运算符无法分配内存时(在<new>中)
bad_array_new_length new运算符被要求创建一个具有无效长度的数组时(在<new>中)
bad_cast dynamic_cast到引用类型失败时(在<typeinfo>中)
bad_exception 发生了意外情况(在<exception>中)
bad_function_call 调用了空的function对象(在<functional>中)
bad_typeid typeid的参数为空时(在<typeinfo>中)
bad_weak_ptr 访问已经销毁的对象的弱指针时(在<memory>中)
domain_error 尝试在操作定义的域之外执行操作时
invalid_argument 当参数使用了无效值时
length_error 尝试超出对象定义的长度时
logic_error 当存在逻辑错误时,例如类不变量或前置条件
out_of_range 尝试访问对象定义范围之外的元素时
overflow_error 当计算结果大于目标类型时
range_error 当计算结果超出类型范围时
runtime_error 当代码范围外发生错误时
system_error 包装操作系统错误的基类(在<system_error>中)
underflow_error 当计算结果低于下限时

在前面的表中提到的所有类都有一个构造函数,该构造函数接受const char*const string&参数,与接受 C 字符串的exception类相反(因此,如果通过string对象传递描述,则使用c_str方法构造基类)。没有宽字符版本,因此如果要从宽字符字符串构造异常描述,必须进行转换。还要注意,标准异常类只有一个构造函数参数,并且可以通过继承的what访问器获得。

关于异常可以持有的数据没有绝对规则。您可以从exception派生一个类,并使用您想要提供给异常处理程序的任何值来构造它。

按类型捕获异常

每个try块可以有多个catch块,这意味着您可以根据异常类型定制异常处理。catch子句中的参数类型将按照它们声明的顺序与异常类型进行测试。异常将由与异常类型匹配的第一个处理程序处理,或者是一个基类。这突出了通过引用捕获异常对象的约定。如果您以基类对象捕获,将会创建一个副本,切割派生类对象。在许多情况下,代码会抛出从exception类派生的类型的对象,这意味着exception的 catch 处理程序将捕获所有异常。

由于代码可以抛出任何对象,因此可能会有异常传播出处理程序。C++允许您使用catch子句中的省略号捕获所有内容。显然,您应该按照从最派生到最不派生的顺序排列catch处理程序,并且(如果使用)将省略号处理程序放在最后:

    try  
    { 
        call_code(); 
    } 
    catch(invalid_argument& iva) 
    { 
        cout << "invalid argument: " << e.what() << endl; 
    } 
    catch(exception& exc) 
    { 
        cout << typeid(exc).name() << ": " << e.what() << endl; 
    } 
    catch(...) 
    { 
        cout << "some other C++ exception" << endl; 
    }

如果受保护的代码没有抛出异常,则catch块不会被执行。

当处理程序检查异常时,可能会决定不想抑制异常;这称为重新抛出异常。为此,您可以使用没有操作数的throw语句(这仅允许在catch处理程序中),它将重新抛出实际捕获的异常对象,而不是副本。

异常是基于线程的,因此很难将异常传播到另一个线程。exception_ptr类(在<exception>中)为任何类型的异常对象提供了共享所有权语义。您可以通过调用make_exception_ptr对象获得异常对象的共享副本,或者甚至可以在catch块中使用current_exception获得正在处理的异常的共享副本。这两个函数都返回一个exception_ptr对象。exception_ptr对象可以持有任何类型的异常,而不仅仅是从exception类派生的异常,因此从包装的异常获取信息是特定于异常类型的。exception_ptr对象对这些细节一无所知,因此您可以将其传递给rethrow_exception,在您想要使用共享异常的上下文中(另一个线程),然后捕获适当的异常对象。在下面的代码中,有两个线程在运行。first_thread函数在一个线程上运行,second_thread函数在另一个线程上运行:

    exception_ptr eptr = nullptr; 

    void first_thread() 
    { 
        try  
        { 
            call_code(); 
        } 
        catch (...)  
        { 
            eptr = current_exception();  
        } 
        // some signalling mechanism ... 
    } 

    void second_thread() 
    { 
        // other code 

        // ... some signalling mechanism 
        if (eptr != nullptr)  
        { 
            try 
            { 
                rethrow_exception(eptr); 
            } 
            catch(my_exception& e) 
            { 
                // process this exception 
            } 
            eptr = nullptr; 
        } 
        // other code 
    }

前面的代码看起来像是使用exception_ptr作为指针。实际上,eptr被创建为全局对象,对nullptr的赋值使用了复制构造函数来创建一个空对象(其中包装的异常是nullptr)。类似地,与nullptr的比较实际上测试了包装的异常。

本书不涉及 C++线程,因此我们不会详细介绍两个线程之间的信号传递。这段代码表明,任何异常的共享副本可以存储在一个上下文中,然后在另一个上下文中重新抛出和处理。

函数 try 块

您可能会决定用try块保护整个函数,这种情况下,您可以编写如下代码:

    void test(double d) 
    { 
        try 
        { 
            cout << setw(10) << d << setw(10) << reciprocal(d) << endl; 
        } 

        catch (exception& e) 
        { 
            cout << "error: " << e.what() << endl; 
        } 
    }

这使用了之前定义的reciprocal函数,如果参数为零,它将抛出一个exception。这种情况的另一种替代语法是:

    void test(double d) 
    try 
    { 
        cout << setw(10) << d << setw(10) << reciprocal(d) << endl; 
    } 
    catch (exception& e) 
    { 
        cout << "error: " << e.what() << endl; 
    }

这看起来相当奇怪,因为函数原型后面紧跟着try... catch块,而且没有外部的大括号。函数体是try块中的代码;当这段代码完成时,函数就会返回。如果函数返回一个值,它必须在try块中返回。在大多数情况下,你会发现这种语法会使你的代码变得不太可读,但有一种情况可能会有用——在构造函数的初始化列表中。

    class inverse 
    { 
        double recip; 
    public: 
        inverse() = delete; 
        inverse(double d) recip(reciprocal(d)) {} 
        double get_recip() const { return recip; } 
    };

在这段代码中,我们包装了一个double值,它只是构造函数参数的倒数。通过在初始化列表中调用reciprocal函数来初始化数据成员。由于这是在构造函数体之外,发生在这里的异常将直接传递给调用构造函数的代码。如果您想进行一些额外的处理,那么您可以在构造函数体内调用倒数函数:

    inverse::inverse(double d)  
    {  
        try { recip = reciprocal(d); } 
        catch(exception& e) { cout << "invalid value " << d << endl; } 
    }

重要的是要注意,异常将被自动重新抛出,因为构造函数中的任何异常意味着对象是无效的。然而,这允许您进行一些额外的处理,如果有必要的话。这种解决方案对于在基对象构造函数中抛出的异常是行不通的,因为虽然您可以在派生构造函数体中调用基构造函数,但编译器会自动调用默认构造函数。如果您希望编译器调用除默认构造函数之外的构造函数,您必须在初始化列表中调用它。在inverse构造函数中提供异常代码的另一种替代语法是使用函数try块:

    inverse::inverse(double d)  
    try 
        : recip (reciprocal(d)) {}  
    catch(exception& e) { cout << "invalid value " << d << endl; }

这看起来有点凌乱,但构造函数体仍然在初始化列表之后,给recip数据成员赋予初始值。对reciprocal的调用中的任何异常都将被捕获并在处理后自动重新抛出。初始化列表可以包含对基类和任何数据成员的调用,所有这些都将受到try块的保护。

系统错误

<system_error>库定义了一系列类来封装系统错误。error_category类提供了一种将数值错误值转换为本地化描述字符串的机制。通过<system_error>中的generic_categorysystem_category函数可以获得两个对象,而<ios>中有一个名为isostream_category的函数;所有这些函数都返回一个error_category对象。error_category类有一个名为message的方法,它返回您传递的错误号的字符串描述。从generic_category函数返回的对象将返回 POSIX 错误的描述字符串,因此您可以使用它来获取errno值的描述。从system_category函数返回的对象将通过 Win32 的FormatMessage函数使用FORMAT_MESSAGE_FROM_SYSTEM作为标志参数返回错误描述,因此可以用它来获取string对象中 Windows 错误消息的描述。

请注意,message没有额外的参数来传递值,以获取需要参数的 Win32 错误消息。因此,在这些情况下,您将得到一个带有格式化占位符的消息。

尽管名字是这样,isostream_category对象实质上返回的是与generic_category对象相同的描述。

system_error异常是一个报告由error_category对象描述的值的类。例如,这是之前使用FormatMessage但使用system_error重新编写的示例:

    HANDLE file = CreateFileA( 
       "does_not_exist", GENERIC_READ, 0, 0, OPEN_EXISTING, 0, 0); 
    if (INVALID_HANDLE_VALUE == file) 
    { 
        throw system_error(GetLastError(), system_category()); 
    } 
    else 
    { 
        CloseHandle(file); 
    }

这里使用的system_error构造函数的第一个参数是错误值(从 Win32 函数GetLastError返回的ulong),第二个参数是用于在调用system_error::what方法时将错误值转换为描述性字符串的system_category对象。

嵌套异常

catch块可以通过调用没有操作数的throw重新抛出当前异常,并且会进行堆栈展开,直到在调用堆栈中达到下一个try块。你还可以将当前异常嵌套在另一个异常内部重新抛出。这是通过调用throw_with_nested函数(在<exception>中)并传递新异常来实现的。该函数调用current_exception并将异常对象与参数一起包装在嵌套异常中,然后抛出。调用堆栈上方的try块可以捕获此异常,但它只能访问外部异常;它无法直接访问内部异常。相反,可以通过调用rethrow_if_nested来抛出内部异常。例如,这是另一个打开文件的代码版本:

    void open(const char *filename) 
    { 
        try  
        { 
            ifstream file(filename); 
            file.exceptions(ios_base::failbit); 
            // code if the file exists 
        } 
        catch (exception& e)  
        { 
            throw_with_nested( 
                system_error(ENOENT, system_category(), filename)); 
        } 
    }

该代码打开一个文件,如果文件不存在,则设置一个状态位(可以稍后使用rdstat方法调用来测试位)。下一行指示应该由抛出异常的类处理的状态位的值,在这种情况下,提供了ios_base::failbit。如果构造函数未能打开文件,则将设置此位,因此exceptions方法将通过抛出异常来响应。在这个例子中,异常被捕获并包装成嵌套异常。外部异常是一个system_error异常,它使用ENOENT的错误值(表示文件不存在)和一个error_category对象来解释它进行初始化,并传递文件名作为附加信息。

可以这样调用这个函数:

    try 
    { 
        open("does_not_exist"); 
    } 
    catch (exception& e) 
    { 
        cout << e.what() << endl; 
    }

在这里捕获的异常可以被访问,但它只提供有关外部对象的信息:

 does_not_exist: The system cannot find the file specified.

这条消息是由system_error对象使用传递给它构造函数的附加信息和类别对象的描述构造的。要获取嵌套异常中的内部对象,必须告诉系统使用rethrow_if_nested调用抛出内部异常。因此,不是打印外部异常,而是调用这样的函数:

    void print_exception(exception& outer) 
    { 
        cout << outer.what() << endl; 
        try { rethrow_if_nested(outer); } 
        catch (exception& inner) { print_exception(inner); } 
    }

这将打印外部异常的描述,然后调用rethrow_if_nested,只有在有嵌套时才会抛出异常。如果是这样,它会抛出内部异常,然后被捕获并递归调用print_exception函数。结果是:

    does_not_exist: The system cannot find the file specified. 
    ios_base::failbit set: iostream stream error

最后一行是在调用ifstream::exception方法时抛出的内部异常。

结构化异常处理

Windows 中的本地异常是结构化异常处理SEH),Visual C有一种语言扩展,允许你捕获这些异常。重要的是要理解它们与 C异常不同,编译器认为 C异常是同步的,也就是说,编译器知道一个方法是否(或者特别地,不会)抛出 C异常,并且在分析代码时使用这些信息。C异常也是按类型捕获的。SEH 不是 C的概念,因此编译器将结构化异常视为异步,这意味着它将任何在 SEH 保护块内的代码视为可能引发结构化异常,因此编译器无法执行优化。SEH 异常也是按异常代码捕获的。

SEH 的语言扩展是 Microsoft C/C的扩展,也就是说,它们可以在 C 和 C中使用,因此处理基础结构不知道对象析构函数。此外,当你捕获一个 SEH 异常时,不会对堆栈或进程的任何其他部分的状态做出任何假设。

尽管大多数 Windows 函数会以适当的方式捕获内核生成的 SEH 异常,但有些函数故意允许它们传播(例如,远程过程调用RPC)函数,或用于内存管理的函数)。对于一些 Windows 函数,您可以明确要求使用 SEH 异常处理错误。例如,HeapCreate函数集允许 Windows 应用程序创建私有堆,您可以传递HEAP_GENERATE_EXCEPTIONS标志以指示在创建堆以及在私有堆中分配或重新分配内存时出现错误将生成 SEH 异常。这是因为调用这些函数的开发人员可能认为失败是如此严重,以至于无法恢复,因此进程应该终止。由于 SEH 是如此严重的情况,您应该仔细审查是否适当(这并非完全不可能)做更多事情,而不仅仅是报告异常的详细信息并终止进程。

SEH 异常本质上是低级操作系统异常,但熟悉其语法很重要,因为它看起来类似于 C++异常。例如:

    char* pPageBuffer; 
    unsigned long curPages = 0; 
    const unsigned long PAGESIZE = 4096; 
    const unsigned long PAGECOUNT = 10; 

    int main() 
    { 
        void* pReserved = VirtualAlloc( 
        nullptr, PAGECOUNT * PAGESIZE, MEM_RESERVE, PAGE_NOACCESS); 
        if (nullptr == pReserved)  
        { 
            cout << "allocation failed" << endl; 
            return 1; 
        } 

        char *pBuffer = static_cast<char*>(pReserved); 
        pPageBuffer = pBuffer; 

        for (int i = 0; i < PAGECOUNT * PAGESIZE; ++i) 
        { 
            __try { pBuffer[i] = 'X'; } __except (exception_filter(GetExceptionCode())) { cout << "Exiting process.n"; ExitProcess(GetLastError()); } 
        } 
        VirtualFree(pReserved, 0, MEM_RELEASE); 
        return 0; 
    }

这里突出显示了 SEH 异常代码。此代码使用 Windows 的VirtualAlloc函数来保留一定数量的内存页。保留不会分配内存,该操作必须在称为提交内存的单独操作中执行。Windows 将以称为的块保留(和提交)内存,在大多数系统上,一页为 4096 字节,如此处所假设的。对VirtualAlloc函数的调用指示应保留 4096 字节的十页,稍后将对其进行提交(和使用)。

VirtualAlloc的第一个参数指示内存的位置,但由于我们正在保留内存,因此这并不重要,因此传递了nullptr。如果保留成功,则会返回指向内存的指针。for循环只是逐字节向内存写入数据。突出显示的代码使用结构化异常处理来保护此内存访问。受保护的块以__try关键字开始。当引发 SEH 时,执行将传递到__except块。这与 C异常中的catch块非常不同。首先,__except异常处理程序接收三个值中的一个,以指示它应该如何行为。只有在这是EXCEPTION_EXECUTE_HANDLER时,处理程序块中的代码才会运行(在此代码中,以突然关闭进程)。如果值是EXCEPTION_CONTINUE_SEARCH,则不会识别异常,并且搜索将继续向上堆栈,*但不会进行 C堆栈展开*。令人惊讶的值是EXCEPTION_CONTINUE_EXECUTION,因为这会解除异常并且__try块中的执行将继续。您无法使用 C++异常做到这一点。通常,SEH 代码将使用异常过滤器函数来确定__except处理程序所需的操作。在此代码中,此过滤器称为exception_filter,它通过调用 Windows 函数GetExceptionCode获取的异常代码进行传递。此语法很重要,因为此函数只能在__except上下文中调用。

第一次循环运行时,不会分配任何内存,因此写入内存的代码将引发异常:页面错误。执行将传递到异常处理程序,然后通过exception_filter

    int exception_filter(unsigned int code) 
    { 
        if (code != EXCEPTION_ACCESS_VIOLATION) 
        { 
            cout << "Exception code = " << code << endl; 
            return EXCEPTION_EXECUTE_HANDLER; 
        } 

        if (curPage >= PAGECOUNT) 
        { 
            cout << "Exception: out of pages.n"; 
            return EXCEPTION_EXECUTE_HANDLER; 
        } 

        if (VirtualAlloc(static_cast<void*>(pPageBuffer), PAGESIZE, 
         MEM_COMMIT, PAGE_READWRITE) == nullptr) 
        { 
            cout << "VirtualAlloc failed.n"; 
            return EXCEPTION_EXECUTE_HANDLER; 
        } 

        curPage++; 
        pPageBuffer += PAGESIZE; 
        return EXCEPTION_CONTINUE_EXECUTION; 
    }

在 SEH 代码中,重要的是只处理您知道的异常,并且只有在您知道条件已完全解决时才消耗异常。如果访问未提交的 Windows 内存,操作系统会生成一个称为页面错误的异常。在此代码中,测试异常代码以查看是否是页面错误,如果不是,则过滤器返回,告诉异常处理程序运行终止进程的异常处理程序块中的代码。如果异常是页面错误,那么我们可以提交下一页。首先,测试页面编号是否在我们将使用的范围内(如果不是,则关闭进程)。然后,使用另一个调用VirtualAlloc来标识要提交的页面和该页面中的字节数,提交下一页。如果函数成功,它将返回指向已提交页面的指针或空值。只有在提交页面成功后,过滤器才会返回EXCEPTION_CONTINUE_EXECUTION的值,表示已处理异常并且可以在引发异常的点继续执行。这段代码是使用VirtualAlloc的标准方式,因为这意味着只有在需要时才会提交内存页面。

SEH 还有终止处理程序的概念。当执行通过调用return离开__try代码块,或者通过完成代码块中的所有代码,或者通过调用 Microsoft 扩展__leave指令,或者引发 SEH 时,标有__finally的终止处理程序代码块将被调用。由于终止处理程序总是被调用,无论__try块如何退出,都可以将其用作释放资源的一种方式。然而,由于 SEH 不进行 C堆栈展开(也不调用析构函数),这意味着您不能在具有 C对象的函数中使用此代码。实际上,编译器将拒绝编译具有 SEH 并创建 C对象的函数,无论是在函数堆栈上还是在堆上分配的对象。(但是,您可以使用全局对象或在调用函数中分配并作为参数传递的对象。)__try/__finally结构看起来很有用,但受到一个限制,即不能与创建 C对象的代码一起使用。

编译器异常开关

在这一点上,值得解释一下为什么要使用/EHsc开关编译代码。简单的答案是,如果不使用此开关,编译器将从标准库代码中发出警告,由于标准库使用异常,您必须使用/EHsc开关。警告告诉您这样做,所以您就这样做了。

长答案是,/EH开关有三个参数,可以影响异常处理方式。使用s参数告诉编译器提供同步异常的基础设施,即在try块中可能抛出并在catch块中处理的 C异常,并且具有调用自动 C对象的析构函数的堆栈展开。c参数表示extern C函数(即所有 Windows SDK 函数)永远不会抛出 C++异常(因此编译器可以进行额外级别的优化)。因此,您可以使用/EHs/EHsc编译标准库代码,但后者将生成更多优化的代码。还有一个额外的参数,其中/EHa表示代码将使用try/catch块捕获同步和异步异常(SEH)。

混合 C++和 SEH 异常处理

RaiseException Windows 函数将引发一个 SEH 异常。第一个参数是异常代码,第二个参数指示处理此异常后进程是否可以继续(0表示可以)。第三个和第四个参数提供有关异常的附加信息。第四个参数是指向包含这些附加参数的数组的指针,第三个参数给出了参数的数量。

使用/EHa,您可以编写如下的代码:

    try  
    { 
        RaiseException(1, 0, 0, nullptr); 
    } 
    // legal code, but don't do it 
    catch(...) 
    { 
        cout << "SEH or C++ exception caught" << endl; 
    }

这段代码的问题在于它处理了所有 SEH 异常。这是非常危险的,因为一些 SEH 异常可能表明进程状态已经损坏,所以让进程继续运行是危险的。C 运行时库提供了一个名为_set_se_translator的函数,它提供了一个机制来指示哪些 SEH 异常由try处理。这个函数通过一个具有以下原型的函数传递一个指针:

    void func(unsigned int, EXCEPTION_POINTERS*);

第一个参数是异常代码(将从GetExceptionCode函数返回),第二个参数是GetExceptionInformation函数的返回值,并带有与异常相关的任何附加参数(例如,通过RaiseException的第三个和第四个参数传递的参数)。您可以使用这些值来抛出 C++异常来代替 SEH。如果您提供了这个函数:

    void seh_to_cpp(unsigned int code, EXCEPTION_POINTERS*) 
    { 
        if (code == 1) throw exception("my error"); 
    }

现在您可以在处理 SEH 异常之前注册该函数:

    _set_se_translator(seh_to_cpp); 
    try  
    { 
        RaiseException(1, 0, 0, nullptr); 
    } 
    catch(exception& e) 
    { 
        cout << e.what() << endl; 
    }

在这段代码中,RaiseException函数正在引发一个值为 1 的自定义 SEH。这种转换可能并不是最有用的,但它说明了要点。winnt.h头文件定义了在 Windows 代码中可以引发的标准 SEH 异常的异常代码。一个更有用的转换函数可能是:

    double reciprocal(double d) 
    { 
        return 1.0 / d; 
    } 

    void seh_to_cpp(unsigned int code, EXCEPTION_POINTERS*) 
    { 
        if (STATUS_FLOAT_DIVIDE_BY_ZERO == code || 
            STATUS_INTEGER_DIVIDE_BY_ZERO == code) 
        { 
            throw invalid_argument("divide by zero"); 
        } 
    }

这使您可以调用如下的逆函数:

    _set_se_translator(seh_to_cpp); 
    try  
    { 
        reciprocal(0.0); 
    } 
    catch(invalid_argument& e) 
    { 
        cout << e.what() << endl; 
    }

编写异常安全的类

一般来说,当您编写类时,应确保保护类的用户免受异常的影响。异常不是错误传播机制。如果类上的方法失败但是可恢复的(对象状态保持一致),那么应该使用返回值(很可能是错误代码)来指示这一点。异常是用于异常情况的,这些情况已经使数据无效,并且在引发异常的地方,情况是无法恢复的。

当您的代码中发生异常时,您有三个选择。首先,您可以允许异常沿着调用堆栈传播,并将处理异常的责任放在调用代码上。这意味着您调用的代码没有通过try块进行保护,即使该代码被记录为可能引发异常。在这种情况下,您必须确保异常对调用代码是有意义的。例如,如果您的类被记录为网络类,并使用临时文件来缓冲从网络接收到的一些数据,如果文件访问代码引发异常,异常对象对调用您的代码的代码来说是没有意义的,因为该客户端代码认为您的类是关于访问网络数据的,而不是文件数据。然而,如果网络代码引发错误,允许这些异常传播到调用代码可能是有意义的,特别是如果它们涉及需要外部操作的错误(比如,网络电缆被拔掉或存在安全问题)。

在这种情况下,您可以应用第二个选项,即使用try块保护可能引发异常的代码,捕获已知异常,并抛出更合适的异常,可能嵌套原始异常,以便调用代码可以进行更详细的分析。如果异常对您的调用代码有意义,您可以允许它传播出去,但捕获原始异常允许您在重新引发异常之前采取额外的操作。

利用缓冲网络数据的例子,你可以决定,由于文件缓冲中存在错误,这意味着你无法再读取任何网络数据,因此你的异常处理代码应该以一种优雅的方式关闭网络访问。错误发生在文件代码中,而不是网络代码中,因此突然关闭网络是不合理的,更合理的做法是允许当前的网络操作完成(但忽略数据),以便不会将错误传播回网络代码。

最后的选择是用try块保护所有代码,并捕获和消耗异常,以便调用代码在不抛出异常的情况下完成。有两种主要情况适合这样做。首先,错误可能是可恢复的,因此在catch子句中,你可以采取措施来解决问题。在缓冲网络数据的例子中,当打开临时文件时,如果出现请求的名称已经存在的文件的错误,你可以简单地使用另一个名称再试一次。你的代码使用者不需要知道发生了这个问题(尽管,在代码测试阶段追踪这个错误可能是有意义的)。如果错误是不可恢复的,可能更合理的做法是使对象的状态无效并返回错误代码。

你的代码应该利用 C++异常基础设施的行为,它保证自动对象被销毁。因此,当你使用内存或其他适当的资源时,你应该尽可能地将它们包装在智能指针中,这样如果抛出异常,资源将由智能指针析构函数释放。使用资源获取即初始化(RAII)的类有vectorstringfstreammake_shared函数,因此如果对象的构造(或函数调用)成功,这意味着资源已经被获取,并且你可以通过这些对象使用资源。这些类也是资源释放销毁RRD),这意味着当对象被销毁时资源被释放。智能指针类unique_ptrshared_ptr不是 RAII,因为它们只是简单地包装资源,资源的分配是由其他代码单独执行的。然而,这些类是 RRD,因此你可以放心,如果抛出异常,资源将被释放。

异常处理可以提供三个级别的异常安全性。在最安全级别的尺度上是无故障的方法和函数。这是不会抛出异常并且不允许异常传播的代码。这样的代码将保证类不变量被维护,并且对象状态将是一致的。无故障的代码不是通过简单地捕获所有异常并消耗它们来实现的,相反,你必须保护所有代码并捕获和处理所有异常,以确保对象处于一致的状态。

所有内置的 C++类型都是无故障的。你还有一个保证,所有标准库类型都有无故障的析构函数,但由于容器在实例被销毁时会调用包含对象的析构函数,这意味着你必须确保你写入容器的类型也有无故障的析构函数。

编写无故障类型可能涉及相当详细的代码,所以另一个选择是强有力的保证。这样的代码会抛出异常,但它们确保没有内存泄漏,并且当抛出异常时,对象将处于与调用方法时相同的状态。这本质上是一个事务操作:要么对象被修改,要么它保持不变,就好像没有尝试执行操作一样。在大多数情况下的方法中,这将提供基本的异常安全保证。在这种情况下,有一个保证,无论发生什么,都不会有内存泄漏,但当抛出异常时,对象可能会处于不一致的状态,因此调用代码应该通过丢弃对象来处理异常。

文档很重要。如果对象的方法标有thrownoexcept,那么你就知道它是无故障的。只有在文档中这样说明时,你才应该假设有强有力的保证。否则,你可以假设对象将具有基本的异常安全保证,如果抛出异常,对象将无效。

总结

当你编写 C代码时,你应该始终关注测试和调试代码。防止调试代码的理想方式是编写健壮、设计良好的代码。理想很难实现,所以最好编写易于诊断问题和易于调试的代码。C 运行时和 C标准库提供了广泛的设施,使你能够跟踪和报告问题,并通过错误代码处理和异常,你有丰富的工具集来报告和处理函数的失败。

阅读完本书后,你应该意识到 C语言和标准库提供了一种丰富、灵活和强大的编写代码的方式。更重要的是,一旦你知道如何使用语言和其库,C就是一种乐趣。

第十一章:音效,文件 I/O 和完成游戏

我们快要完成了。这一小节将演示如何使用 C++标准库轻松操作存储在硬盘上的文件,我们还将添加音效。当然,我们知道如何添加音效,但我们将讨论在代码中play的调用应该放在哪里。我们还将解决一些问题,使游戏更完整。

在本章中,我们将学习以下主题:

  • 保存和加载最高分

  • 添加音效

  • 允许玩家升级

  • 创建无尽的多波

保存和加载最高分

文件 I/O,即输入/输出,是一个相当技术性的主题。幸运的是,由于它在编程中是一个如此常见的需求,有一个库可以处理所有复杂性。与我们为 HUD 连接字符串一样,标准库通过fstream提供了必要的功能。

首先,我们以与包含sstream相同的方式包含fstream

#include "stdafx.h" 
#include <sstream> 
#include <fstream> 
#include <SFML/Graphics.hpp> 
#include "ZombieArena.h" 
#include "Player.h" 
#include "TextureHolder.h" 
#include "Bullet.h" 
#include "Pickup.h" 

using namespace sf; 

现在,在ZombieArena/ZombieArena文件夹中添加一个名为gamedata的新文件夹。接下来,在此文件夹中右键单击并创建一个名为scores.txt的新文件。在这个文件中,我们将保存玩家的最高分。您可以打开文件并向其中添加分数。如果您这样做,请确保它是一个相当低的分数,这样我们就可以很容易地测试是否击败该分数会导致新分数被添加。确保在完成后关闭文件,否则游戏将无法访问它。

在下一段代码中,我们创建一个名为InputFileifstream对象,并将刚刚创建的文件夹和文件作为参数发送到它的构造函数。

if(InputFile.is_open())代码检查文件是否存在并准备好从中读取。然后我们将文件的内容放入hiScore中,并关闭文件。添加突出显示的代码:

// Score 
Text scoreText; 
scoreText.setFont(font); 
scoreText.setCharacterSize(55); 
scoreText.setFillColor(Color::White); 
scoreText.setPosition(20, 0); 

// Load the high-score from a text file
std::ifstream inputFile("gamedata/scores.txt");
if (inputFile.is_open())
{
   inputFile >> hiScore;
   inputFile.close();
} 

// Hi Score 
Text hiScoreText; 
hiScoreText.setFont(font); 
hiScoreText.setCharacterSize(55); 
hiScoreText.setFillColor(Color::White); 
hiScoreText.setPosition(1400, 0); 
std::stringstream s; 
s << "Hi Score:" << hiScore; 
hiScoreText.setString(s.str()); 

现在我们处理保存可能的新高分。在处理玩家生命值小于或等于零的代码块中,我们创建一个名为outputFileofstream对象,将hiScore的值写入文本文件,然后关闭文件:

// Have any zombies touched the player        
for (int i = 0; i < numZombies; i++) 
{ 
   if (player.getPosition().intersects 
      (zombies[i].getPosition()) && zombies[i].isAlive()) 
   { 

      if (player.hit(gameTimeTotal)) 
      { 
         // More here later 
      } 

      if (player.getHealth() <= 0) 
      { 
        state = State::GAME_OVER; 

 std::ofstream outputFile("gamedata/scores.txt");
        outputFile << hiScore;
        outputFile.close(); 

      } 
   } 
}// End player touched 

您可以玩游戏,您的最高分将被保存。退出游戏并注意,如果您再次玩游戏,您的最高分仍然存在。

让我们制造一些噪音。

准备音效

在本节中,我们将创建所有我们需要为游戏添加一系列音效的SoundBufferSound对象。

首先添加所需的 SFML 包括:

#include "stdafx.h" 
#include <sstream> 
#include <fstream> 
#include <SFML/Graphics.hpp> 
#include <SFML/Audio.hpp> 
#include "ZombieArena.h" 
#include "Player.h" 
#include "TextureHolder.h" 
#include "Bullet.h" 
#include "Pickup.h" 

现在继续添加七个SoundBufferSound对象,它们加载和准备了我们在第六章中准备的七个音频文件:面向对象编程,类和 SFML 视图

// When did we last update the HUD? 
int framesSinceLastHUDUpdate = 0; 
// What time was the last update 
Time timeSinceLastUpdate; 
// How often (in frames) should we update the HUD 
int fpsMeasurementFrameInterval = 1000; 

// Prepare the hit sound
SoundBuffer hitBuffer;
hitBuffer.loadFromFile("sound/hit.wav");
Sound hit;
hit.setBuffer(hitBuffer);

// Prepare the splat sound
SoundBuffer splatBuffer;
splatBuffer.loadFromFile("sound/splat.wav");
sf::Sound splat;
splat.setBuffer(splatBuffer);

// Prepare the shoot soundSoundBuffer shootBuffer;shootBuffer.loadFromFile("sound/shoot.wav");
Sound shoot;shoot.setBuffer(shootBuffer);

// Prepare the reload sound
SoundBuffer reloadBuffer;
reloadBuffer.loadFromFile("sound/reload.wav");
Sound reload;
reload.setBuffer(reloadBuffer);

// Prepare the failed sound
SoundBuffer reloadFailedBuffer;
reloadFailedBuffer.loadFromFile("sound/reload_failed.wav");
Sound reloadFailed;
reloadFailed.setBuffer(reloadFailedBuffer);

// Prepare the powerup sound
SoundBuffer powerupBuffer;
powerupBuffer.loadFromFile("sound/powerup.wav");
Sound powerup;
powerup.setBuffer(powerupBuffer);

// Prepare the pickup sound
SoundBuffer pickupBuffer;
pickupBuffer.loadFromFile("sound/pickup.wav");
Sound pickup;
pickup.setBuffer(pickupBuffer); 

// The main game loop 
while (window.isOpen()) 

现在七种音效已经准备好播放。我们只需要弄清楚在我们的代码中每个play函数的调用将放在哪里。

升级

接下来我们要添加的代码使玩家可以在波之间升级。由于我们已经做过的工作,这是很容易实现的。

LEVELING_UP状态中添加突出显示的代码,我们处理玩家输入:

// Handle the LEVELING up state 
if (state == State::LEVELING_UP) 
{ 
   // Handle the player LEVELING up 
   if (event.key.code == Keyboard::Num1) 
   { 
 // Increase fire rate
     fireRate++; 
     state = State::PLAYING; 
   } 

   if (event.key.code == Keyboard::Num2) 
   { 
 // Increase clip size
     clipSize += clipSize; 
     state = State::PLAYING; 
   } 

   if (event.key.code == Keyboard::Num3) 
   { 
 // Increase health
     player.upgradeHealth(); 
     state = State::PLAYING; 
   } 

   if (event.key.code == Keyboard::Num4) 
   { 
 // Increase speed
     player.upgradeSpeed(); 
     state = State::PLAYING; 
   } 

   if (event.key.code == Keyboard::Num5) 
   { 
 // Upgrade pickup
     healthPickup.upgrade(); 
     state = State::PLAYING; 
   } 

   if (event.key.code == Keyboard::Num6) 
   { 
 // Upgrade pickup
     ammoPickup.upgrade(); 
     state = State::PLAYING; 
   } 

   if (state == State::PLAYING) 
   { 

玩家现在可以在清除一波僵尸时升级。但是,我们目前还不能增加僵尸的数量或级别的大小。

LEVELING_UP状态的下一部分,在我们刚刚添加的代码之后,修改当状态从LEVELING_UP变为PLAYING时运行的代码。

以下是完整的代码。我已经突出显示了要么是新的要么已经稍作修改的行。

添加或修改突出显示的代码:

   if (event.key.code == Keyboard::Num6) 
   { 
      ammoPickup.upgrade(); 
      state = State::PLAYING; 
   } 

   if (state == State::PLAYING) 
   { 
 // Increase the wave number
     wave++; 

     // Prepare thelevel 
     // We will modify the next two lines later 
 arena.width = 500 * wave;
     arena.height = 500 * wave; 
     arena.left = 0; 
     arena.top = 0; 

     // Pass the vertex array by reference  
     // to the createBackground function 
     int tileSize = createBackground(background, arena); 

     // Spawn the player in the middle of the arena 
     player.spawn(arena, resolution, tileSize); 

     // Configure the pickups 
     healthPickup.setArena(arena); 
     ammoPickup.setArena(arena); 

     // Create a horde of zombies 
 numZombies = 5 * wave; 

     // Delete the previously allocated memory (if it exists) 
     delete[] zombies; 
     zombies = createHorde(numZombies, arena); 
     numZombiesAlive = numZombies; 

 // Play the powerup sound
     powerup.play(); 

     // Reset the clock so there isn't a frame jump 
     clock.restart(); 
   } 
}// End LEVELING up 

前面的代码首先递增wave变量。然后修改代码,使僵尸的数量和竞技场的大小与wave的新值相关。最后,我们添加了powerup.play()的调用,以播放升级音效。

重新开始游戏

我们已经通过wave变量的值确定了竞技场的大小和僵尸的数量。我们还必须在每次新游戏开始时将弹药、枪支、wavescore重置为零。在游戏循环的事件处理部分中找到以下代码,并添加突出显示的代码:

// Start a new game while in GAME_OVER state 
else if (event.key.code == Keyboard::Return && 
   state == State::GAME_OVER) 
{ 
   state = State::LEVELING_UP; 
 wave = 0;
   score = 0;

   // Prepare the gun and ammo for next game
   currentBullet = 0;
   bulletsSpare = 24;
   bulletsInClip = 6;
   clipSize = 6;
   fireRate = 1;

   // Reset the player's stats
   player.resetPlayerStats(); 
} 

现在我们可以玩游戏了,玩家可以变得更加强大,僵尸在不断增加的竞技场中也会变得更加众多,直到他死亡,然后一切重新开始。

播放其余的声音

现在我们将添加对play函数的其余调用。我们会分别处理它们,因为准确确定它们的位置对于在正确时刻播放它们至关重要。

在玩家重新加载时添加音效

在玩家按下R键尝试重新加载枪支时,在三个地方添加突出显示的代码以播放适当的reloadreloadFailed声音:

if (state == State::PLAYING) 
{ 
   // Reloading 
   if (event.key.code == Keyboard::R) 
   { 
      if (bulletsSpare >= clipSize) 
      { 
         // Plenty of bullets. Reload. 
         bulletsInClip = clipSize; 
         bulletsSpare -= clipSize;      
 reload.play(); 
      } 
      else if (bulletsSpare > 0) 
      { 
         // Only few bullets left 
         bulletsInClip = bulletsSpare; 
         bulletsSpare = 0;           
 reload.play(); 
      } 
      else 
      { 
         // More here soon?! 
 reloadFailed.play(); 
      } 
   } 
} 

制作射击声音

在处理玩家点击鼠标左键的代码的末尾附近添加对shoot.play()的突出调用:

// Fire a bullet 
if (sf::Mouse::isButtonPressed(sf::Mouse::Left)) 
{ 

   if (gameTimeTotal.asMilliseconds() 
      - lastPressed.asMilliseconds() 
      > 1000 / fireRate && bulletsInClip > 0) 
   { 

      // Pass the centre of the player and crosshair 
      // to the shoot function 
      bullets[currentBullet].shoot( 
         player.getCenter().x, player.getCenter().y, 
         mouseWorldPosition.x, mouseWorldPosition.y); 

      currentBullet++; 
      if (currentBullet > 99) 
      { 
         currentBullet = 0; 
      } 
      lastPressed = gameTimeTotal; 

 shoot.play(); 

      bulletsInClip--; 
   } 

}// End fire a bullet 

玩家被击中时播放声音

在下面的代码中,我们将对hit.play的调用包装在一个测试中,以查看player.hit函数是否返回true。请记住,player.hit函数用于测试前 100 毫秒内是否记录了击中。这将导致播放一个快速、重复的、沉闷的声音,但不会太快以至于声音模糊成一个噪音。

按照突出显示的方式添加对hit.play的调用:

// Have any zombies touched the player        
for (int i = 0; i < numZombies; i++) 
{ 
   if (player.getPosition().intersects 
      (zombies[i].getPosition()) && zombies[i].isAlive()) 
   { 

      if (player.hit(gameTimeTotal)) 
      { 
         // More here later 
 hit.play(); 
      } 

      if (player.getHealth() <= 0) 
      { 
         state = State::GAME_OVER; 

         std::ofstream OutputFile("gamedata/scores.txt"); 
         OutputFile << hiScore; 
         OutputFile.close(); 

      } 
   } 
}// End player touched 

获得拾取时播放声音

当玩家拾取生命值时,我们将播放常规的拾取声音,但当玩家获得弹药时,我们会播放重新加载的音效。

在适当的碰撞检测代码中,按照突出显示的方式添加两个播放声音的调用:

// Has the player touched health pickup 
if (player.getPosition().intersects 
   (healthPickup.getPosition()) && healthPickup.isSpawned()) 
{ 
   player.increaseHealthLevel(healthPickup.gotIt()); 
 // Play a sound
   pickup.play(); 

} 

// Has the player touched ammo pickup 
if (player.getPosition().intersects 
   (ammoPickup.getPosition()) && ammoPickup.isSpawned()) 
{ 
   bulletsSpare += ammoPickup.gotIt(); 
 // Play a sound
   reload.play(); 

} 

射中僵尸时制作尖啸声

在检测子弹与僵尸碰撞的代码部分的末尾添加对splat.play的调用:

// Have any zombies been shot? 
for (int i = 0; i < 100; i++) 
{ 
   for (int j = 0; j < numZombies; j++) 
   { 
      if (bullets[i].isInFlight() &&  
         zombies[j].isAlive()) 
      { 
         if (bullets[i].getPosition().intersects 
            (zombies[j].getPosition())) 
         { 
            // Stop the bullet 
            bullets[i].stop(); 

            // Register the hit and see if it was a kill 
            if (zombies[j].hit()) { 
               // Not just a hit but a kill too 
               score += 10; 
               if (score >= hiScore) 
               { 
                  hiScore = score; 
               } 

               numZombiesAlive--; 

               // When all the zombies are dead (again) 
               if (numZombiesAlive == 0) { 
                  state = State::LEVELING_UP; 
               } 
            }   

 // Make a splat sound
           splat.play(); 

         } 
      } 

   } 
}// End zombie being shot 

现在你可以玩完整的游戏,并观察每一波僵尸和竞技场的数量增加。谨慎选择你的升级:

射中僵尸时制作尖啸声

恭喜!

常见问题解答

以下是一些可能会让你在意的问题:

Q)尽管使用了类,我发现代码变得非常冗长和难以管理,再次出现了这个问题。

A)最大的问题之一是我们的代码结构。随着我们学习更多的 C++,我们还将学习如何使代码更易管理,通常更短。

Q)声音效果似乎有点单调和不真实。如何改进它们?

A)显著改善玩家从声音中获得的感觉的一种方法是使声音具有方向性,并根据声源到玩家角色的距离改变音量。在下一个项目中,我们将使用 SFML 的高级声音功能。

摘要

我们已经完成了僵尸竞技场游戏。这是一段相当艰难的旅程。我们学到了许多 C基础知识,比如引用、指针、面向对象编程和类。此外,我们还使用 SFML 来管理摄像机、顶点数组和碰撞检测。我们学会了如何使用精灵表来减少对window.draw的调用次数并提高帧率。使用 C指针、STL 和一点面向对象编程,我们构建了一个单例类来管理我们的纹理,在下一个项目中,我们将扩展这个想法来管理我们游戏的所有资源。

在书的结束项目中,我们将发现粒子效果、定向声音和分屏多人游戏。在 C++中,我们还将遇到继承、多态性以及一些新概念。

第十二章:抽象和代码管理——更好地利用 OOP

在本章中,我们将首次查看本书的最终项目。该项目将具有高级功能,如方向性声音,根据玩家位置从扬声器发出。它还将具有分屏合作游戏。此外,该项目将引入着色器的概念,这是用另一种语言编写的程序,直接在图形卡上运行。到第十六章结束时,您将拥有一个完全功能的多人平台游戏,以命中经典Thomas Was Alone的风格构建。

本章的主要重点将是启动项目,特别是探讨如何构建代码以更好地利用 OOP。将涵盖以下主题:

  • 介绍最终项目Thomas Was Late,包括游戏特点和项目资产

  • 与以前的项目相比,我们将讨论如何改进代码结构

  • 编写 Thomas Was Late 游戏引擎

  • 实现分屏功能

Thomas Was Late 游戏

此时,如果您还没有,我建议您去观看 Thomas Was Alone 的视频store.steampowered.com/app/220780/。请注意其简单但美观的图形。视频还展示了各种游戏挑战,例如使用角色的不同属性(高度、跳跃、力量等)。为了保持我们的游戏简单而不失挑战,我们将比 Thomas Was Alone 少一些谜题特色,但将增加两名玩家合作游戏的挑战。为了确保游戏不会太容易,我们还将让玩家与时间赛跑,这就是我们的游戏名为 Thomas Was Late 的原因。

Thomas Was Late 的特点

我们的游戏不会像我们试图模仿的杰作那样先进,但它将具有一系列令人兴奋的游戏特色:

  • 一个从与关卡挑战相适应的时间开始倒计时的时钟。

  • 发射火坑会根据玩家位置发出咆哮声,并在玩家掉下去时重新开始。水坑也有相同的效果,但没有方向性声音效果。

  • 合作游戏——两名玩家必须在规定时间内将他们的角色带到目标处。他们经常需要合作,例如身材较矮、跳跃较低的 Bob 需要站在他朋友(Thomas)的头上。

  • 玩家将有选择在全屏和分屏之间切换,这样他可以尝试自己控制两个角色。

  • 每个关卡将设计并从文本文件中加载。这将使设计各种各样的关卡变得非常容易。

看一下游戏的注释截图,看看一些功能的运行和组件/资产组成游戏:

Thomas Was Late 的特点

让我们看看这些特点,并描述一些更多的特点:

  • 截图显示了一个简单的 HUD,详细说明了关卡编号和玩家失败并重新开始关卡之前剩余的秒数。

  • 您还可以清楚地看到分屏合作游戏的运行情况。请记住这是可选的。单人可以全屏玩游戏,同时在 Thomas 和 Bob 之间切换摄像头焦点。

  • 在截图中并不太清楚(尤其是在打印品中),但当角色死亡时,他将爆炸成星花/烟火般的粒子效果。

  • 水和火砖可以被策略性地放置,使关卡变得有趣,并迫使角色之间合作。更多内容请参阅第十四章, 构建可玩关卡和碰撞检测

  • 注意托马斯和鲍勃——他们不仅身高不同,而且跳跃能力也有很大差异。这意味着鲍勃依赖于托马斯进行大跳跃,关卡可以设计成迫使托马斯选择避免碰头的路线。

  • 此外,火砖将发出隆隆的声音。这些声音将与托马斯的位置相关。它们不仅是定向的,而且会从左侧或右侧扬声器发出,随着托马斯靠近或远离源头,声音会变得越来越大或越来越小。

  • 最后,在带注释的截图中,您可以看到背景。如果您将其与background.png文件(本章后面显示)进行比较,您会发现它们是完全不同的。我们将在第十六章, 扩展 SFML 类、粒子系统和着色器中使用 OpenGL 着色器效果来实现背景中移动的——几乎是冒泡的——效果。

所有这些特点都值得再多拍几张截图,这样我们在编写 C++代码时可以记住最终成品。

以下截图显示了托马斯和鲍勃到达一个火坑,鲍勃没有机会跳过去:

《托马斯迟到的特点》

以下截图显示了鲍勃和托马斯合作清除一个危险的跳跃:

《托马斯迟到的特点》

以下截图显示了我们如何设计需要“信仰之跃”才能达到目标的谜题:

《托马斯迟到的特点》

以下截图演示了我们如何设计几乎任何大小的压抑洞穴系统。我们还可以设计需要鲍勃和托马斯分开并走不同路线的关卡:

《托马斯迟到的特点》

从模板创建项目

创建《托马斯迟到》项目与其他两个项目相同。只需在 Visual Studio 中按照以下简单步骤进行操作:

  1. 从主菜单中选择文件 | 新建项目

  2. 确保在左侧菜单中选择了Visual C++,然后从所呈现的选项列表中选择HelloSFML。以下截图应该能清楚地说明这一点:从模板创建项目

  3. 名称:字段中,键入TWL,并确保选中为解决方案创建目录选项。现在点击确定

  4. 现在我们需要将 SFML 的.dll文件复制到主项目目录中。我的主项目目录是D:\Visual Studio Stuff\Projects\ TWL\TWL。这个文件夹是在上一步中由 Visual Studio 创建的。如果您将Projects文件夹放在其他地方,请在那里执行此步骤。我们需要复制到project文件夹中的文件位于您的SFML\bin文件夹中。为每个位置打开一个窗口,并突出显示所需的.dll文件。

  5. 现在将突出显示的文件复制并粘贴到项目中。

项目现在已经设置好,准备就绪。

项目资产

该项目的资产比僵尸竞技场游戏的资产更多样化和丰富。通常情况下,资产包括屏幕上的文字字体、不同动作的声音效果(如跳跃、达到目标或远处火焰的咆哮)以及托马斯和鲍勃的图形以及所有背景瓷砖的精灵表。

游戏所需的所有资产都包含在下载包中。它们分别位于第十二章/graphics第十二章/sound文件夹中。

所需的字体没有提供。这是因为我想避免任何可能的许可歧义。不过这不会造成问题,因为我会准确地告诉你在哪里以及如何为自己选择和下载字体。

虽然我会提供资产本身或者获取它们的信息,但你可能想要自己创建和获取它们。

除了我们期望的图形、声音和字体之外,这款游戏还有两种新的资产类型。它们是关卡设计文件和 GLSL 着色器程序。让我们接下来了解一下它们各自的情况。

游戏关卡设计

所有的关卡都是在一个文本文件中创建的。通过使用数字 0 到 3,我们可以构建挑战玩家的关卡设计。所有的关卡设计都在 levels 文件夹中,与其他资产在同一个目录中。现在可以随意偷看一下,但我们将在第十四章《构建可玩关卡和碰撞检测》中详细讨论它们。

除了这些关卡设计资产,我们还有一种特殊类型的图形资产,称为着色器。

GLSL 着色器

着色器是用GLSL(图形库着色语言)编写的程序。不用担心必须学习另一种语言,因为我们不需要深入学习就能利用着色器。着色器很特殊,因为它们是完整的程序,与我们的 C++代码分开,由 GPU 每一帧执行。事实上,一些着色器程序每一帧都会运行,对于每一个像素!我们将在第十六章《扩展 SFML 类、粒子系统和着色器》中了解更多细节。如果你等不及,可以看一下下载包的Chapter 12/shaders文件夹中的文件。

图形资产的近距离

游戏的场景由图形资产组成。看一下图形资产,就可以清楚地知道它们在游戏中将被用在哪里:

图形资产的近距离

如果tiles_sheet图形上的瓷砖看起来和游戏截图中的有些不同,这是因为它们部分透明,透过的背景会使它们有些变化。如果背景图看起来和游戏截图中的实际背景完全不同,那是因为我们将编写的着色器程序将操纵每一个像素,每一帧,以创建一种"熔岩"效果。

声音资产的近距离

声音文件都是.wav格式。这些文件包含了我们在游戏中特定事件中播放的声音效果。它们如下:

  • fallinfire.wav:当玩家的头进入火焰并且玩家没有逃脱的机会时,会播放这个声音。

  • fallinwater.wav:水和火有相同的效果:死亡。这个声音效果通知玩家他们需要从关卡的开始处重新开始。

  • fire1.wav:这个声音效果是单声道录制的。它将根据玩家距离火砖的距离和不同的扬声器播放不同的音量,根据玩家是在火砖的左侧还是右侧播放。显然,我们需要学习一些更多的技巧来实现这个功能。

  • jump.wav:当玩家跳跃时,会发出一种愉悦(略显可预测)的欢呼声。

  • reachgoal.wav:当玩家(或玩家们)将两个角色(Thomas 和 Bob)都带到目标砖时,会发出一种愉悦的胜利声音。

声音效果非常简单,你可以很容易地创建自己的声音。如果你打算替换fire1.wav文件,请确保将你的声音保存为单声道(而不是立体声)格式。这个原因将在第十五章《声音空间化和 HUD》中解释。

将资产添加到项目中

一旦您决定使用哪些资产,就该将它们添加到项目中了。以下说明将假定您使用了书籍下载包中提供的所有资产。

如果您使用自己的资产,只需用您选择的文件替换相应的声音或图形文件,文件名完全相同即可:

  1. 浏览到 Visual D:\Visual Studio Stuff\Projects\TWL\TWL目录。

  2. 在此文件夹中创建五个新文件夹,并将它们命名为graphicssoundfontsshaderslevels

  3. 从下载包中,将第十二章/图形文件夹中的所有内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\graphics文件夹中。

  4. 从下载包中,将第十二章/声音文件夹中的所有内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\sound文件夹中。

  5. 现在在您的网络浏览器中访问www.dafont.com/roboto.font,并下载Roboto Light字体。

  6. 解压缩下载的内容,并将Roboto-Light.ttf文件添加到D:\Visual Studio Stuff\Projects\TWL\TWL\fonts文件夹中。

  7. 从下载包中,将第十二章/关卡文件夹中的所有内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\levels文件夹中。

  8. 从下载包中,将第十二章/着色器文件夹中的所有内容复制到D:\Visual Studio Stuff\Projects\TWL\TWL\shaders文件夹中。

现在我们有了一个新项目,以及整个项目所需的所有资产,我们可以讨论如何构建游戏引擎代码。

结构化 Thomas Was Late 代码

到目前为止,两个项目中都存在一个问题,那就是代码变得越来越长和难以控制。面向对象编程允许我们将项目分解为称为类的逻辑和可管理的块。

通过引入Engine 类,我们将在这个项目中大大改善代码的可管理性。除其他功能外,Engine 类将有三个私有函数。它们是inputupdatedraw。这应该听起来很熟悉。这些函数中的每一个将包含以前全部在main函数中的代码块。这些函数将分别在自己的代码文件中,Input.cppUpdate.cppDraw.cpp中。

Engine类中还将有一个公共函数,可以使用Engine的实例调用。这个函数是run,将负责调用inputupdatedraw,每帧游戏调用一次:

结构化 Thomas Was Late 代码

此外,由于我们已经将游戏引擎的主要部分抽象到了Engine类中,我们还可以将main中的许多变量移到Engine的成员中。我们只需要创建一个Engine的实例并调用它的run函数,就可以启动我们的游戏引擎。以下是一个简单的main函数的预览:

int main() 
{ 
   // Declare an instance of Engine 
   Engine engine; 

   // Start the engine 
   engine.run(); 

   // Quit in the usual way when the engine is stopped 
   return 0; 
} 

提示

暂时不要添加前面的代码。

为了使我们的代码更易于管理和阅读,我们还将抽象出一些大任务的责任,例如加载关卡和碰撞检测,放到单独的函数中(在单独的代码文件中)。这两个函数是loadLeveldetectCollisions。我们还将编写其他函数来处理 Thomas Was Late 项目的一些新功能。我们将在出现时详细介绍它们。

为了更好地利用面向对象编程,我们将完全将游戏特定领域的责任委托给新的类。您可能还记得以前项目中的声音和 HUD 代码非常冗长。我们将构建SoundManagerHUD类来更清晰地处理这些方面。当我们实现它们时,将深入探讨它们的工作原理。

游戏关卡本身比以前的游戏更加深入,因此我们还将编写一个LevelManager类。

正如您所期望的,可玩角色也将使用类制作。但是,对于这个项目,我们将学习更多的 C++并实现一个PlayableCharacter类,其中包含 Thomas 和 Bob 的所有常见功能,然后ThomasBob类,它们将继承这些常见功能,并实现自己的独特功能和能力。这可能并不奇怪,被称为继承。我将在接下来的第十三章,“高级面向对象编程,继承和多态”中更详细地介绍继承。

我们还将实现一些其他类来执行特定的职责。例如,我们将使用粒子系统制作一些漂亮的爆炸效果。您可能能够猜到,为了做到这一点,我们将编写一个Particle类和一个ParticleSystem类。所有这些类都将作为Engine类的成员实例。以这种方式做事将使游戏的所有功能都可以从游戏引擎中访问,但将细节封装到适当的类中。

在我们继续查看将创建引擎类的实际代码之前,要提到的最后一件事是,我们将重用在僵尸竞技场游戏中讨论和编写的TextureHolder类,而且不会有任何改变。

构建游戏引擎

如前面讨论所建议的,我们将编写一个名为Engine的类,它将控制并绑定 Thomas Was Late 游戏的不同部分。

我们要做的第一件事是使上一个项目中的TextureHolder类在这个项目中可用。

重用 TextureHolder 类

我们讨论并为僵尸竞技场游戏编写的TextureHolder类在这个项目中也会很有用。虽然可以直接从上一个项目添加文件(TextureHolder.hTextureHolder.cpp)而不需要重新编码或重新创建文件,但我不想假设你没有直接跳到这个项目。接下来是非常简要的说明,以及创建TextureHolder类的完整代码清单。如果您想要解释这个类或代码,请参阅第八章,“指针、标准模板库和纹理管理”。

提示

如果您完成了上一个项目,并且确实想要从僵尸竞技场项目中添加类,只需执行以下操作:在解决方案资源管理器窗口中,右键单击头文件,然后选择添加 | 现有项...。浏览到上一个项目的TextureHolder.h并选择它。在解决方案资源管理器窗口中,右键单击源文件,然后选择添加 | 现有项...。浏览到上一个项目的TextureHolder.cpp并选择它。现在您可以在这个项目中使用TextureHolder类。请注意,文件在项目之间共享,任何更改都将在两个项目中生效。

要从头开始创建TextureHolder类,请在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件(.h),然后在名称字段中键入TextureHolder.h。最后,单击添加按钮。

将以下代码添加到TextureHolder.h

#pragma once 
#ifndef TEXTURE_HOLDER_H 
#define TEXTURE_HOLDER_H 

#include <SFML/Graphics.hpp> 
#include <map> 

class TextureHolder 
{ 
private: 
   // A map container from the STL, 
   // that holds related pairs of String and Texture 
   std::map<std::string, sf::Texture> m_Textures; 

   // A pointer of the same type as the class itself 
   // the one and only instance 
   static TextureHolder* m_s_Instance; 

public: 
   TextureHolder(); 
   static sf::Texture& GetTexture(std::string const& filename); 

}; 

#endif 

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp ,然后在名称字段中键入TextureHolder.cpp。最后,单击添加按钮。

将以下代码添加到TextureHolder.cpp

#include "stdafx.h" 
#include "TextureHolder.h" 
#include <assert.h> 

using namespace sf; 
using namespace std; 

TextureHolder* TextureHolder::m_s_Instance = nullptr; 

TextureHolder::TextureHolder() 
{ 
   assert(m_s_Instance == nullptr); 
   m_s_Instance = this; 
} 

sf::Texture& TextureHolder::GetTexture(std::string const& filename) 
{ 
   // Get a reference to m_Textures using m_S_Instance 
   auto& m = m_s_Instance->m_Textures; 
   // auto is the equivalent of map<string, Texture> 

   // Create an iterator to hold a key-value-pair (kvp) 
   // and search for the required kvp 
   // using the passed in file name 
   auto keyValuePair = m.find(filename); 
   // auto is equivalent of map<string, Texture>::iterator 

   // Did we find a match? 
   if (keyValuePair != m.end()) 
   { 
      // Yes 
      // Return the texture, 
      // the second part of the kvp, the texture 
      return keyValuePair->second; 
   } 
   else 
   { 
      // File name not found 
      // Create a new key value pair using the filename 
      auto& texture = m[filename]; 
      // Load the texture from file in the usual way 
      texture.loadFromFile(filename); 

      // Return the texture to the calling code 
      return texture; 
   } 
} 

我们现在可以继续使用我们的新Engine类。

编写 Engine.h

像往常一样,我们将从头文件开始,其中包含函数声明和成员变量。请注意,我们将在整个项目中重新访问此文件,以添加更多函数和成员变量。目前,我们将只添加在此阶段必要的代码。

解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件(.h,然后在名称字段中输入Engine.h。最后,单击添加按钮。现在我们准备为Engine类编写头文件。

添加以下成员变量以及函数声明。其中许多我们在其他项目中已经见过,有些我们在Structuring the Thomas Was Late代码部分讨论过。请注意函数和变量的名称,以及它们是私有的还是公共的。添加以下代码到Engine.h文件,然后我们将讨论它:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "TextureHolder.h" 

using namespace sf; 

class Engine 
{ 
private: 
   // The texture holder 
   TextureHolder th; 

   const int TILE_SIZE = 50; 
   const int VERTS_IN_QUAD = 4; 

   // The force pushing the characters down 
   const int GRAVITY = 300; 

   // A regular RenderWindow 
   RenderWindow m_Window; 

   // The main Views 
   View m_MainView; 
   View m_LeftView; 
   View m_RightView; 

   // Three views for the background 
   View m_BGMainView; 
   View m_BGLeftView; 
   View m_BGRightView; 

   View m_HudView; 

   // Declare a sprite and a Texture  
   // for the background 
   Sprite m_BackgroundSprite; 
   Texture m_BackgroundTexture; 

   // Is the game currently playing? 
   bool m_Playing = false; 

   // Is character 1 or 2 the current focus? 
   bool m_Character1 = true; 

   // Start in fullscreen mode 
   bool m_SplitScreen = false; 

   // How much time is left in the current level 
   float m_TimeRemaining = 10; 
   Time m_GameTimeTotal; 

   // Is it time for a new/first level? 
   bool m_NewLevelRequired = true; 

   // Private functions for internal use only 
   void input(); 
   void update(float dtAsSeconds); 
   void draw(); 

public: 
   // The Engine constructor 
   Engine(); 

   // Run will call all the private functions 
   void run(); 

}; 

这是所有私有变量和函数的完整说明。在适当的情况下,我会更详细地解释一下:

  • TextureHolder thTextureHolder 类的唯一实例。

  • TILE_SIZE:一个有用的常量,提醒我们精灵表中的每个瓷砖都是 50 像素宽和 50 像素高。

  • VERTS_IN_QUAD:一个有用的常量,使我们对 VertexArray 的操作不那么容易出错。事实上,一个四边形中有四个顶点。现在我们不会忘记它了。

  • GRAVITY:一个表示游戏角色每秒向下推动的像素数的常量int值。一旦游戏完成,这个值就变得非常有趣。我们将其初始化为300,因为这对我们最初的关卡设计效果很好。

  • m_Window:通常的 RenderWindow 对象,就像我们在所有项目中都有的那样。

  • SFML View 对象,m_MainViewm_LeftViewm_RightViewm_BGMainViewm_BGLeftViewm_BGRightViewm_HudView:前三个 View 对象用于全屏视图,左右分屏游戏视图。我们还为这三个视图中的每一个单独创建了一个 SFML View 对象,用于绘制背景。最后一个 View 对象 m_HudView 将在其他六个视图的适当组合上方绘制,以显示得分、剩余时间和向玩家发送的任何消息。有七个不同的 View 对象可能意味着复杂性,但当您在本章的进展中看到我们如何处理它们时,您会发现它们非常简单。我们将在本章结束时解决整个分屏/全屏问题。

  • Sprite m_BackgroundSpriteTexture m_BackgroundTexture:可以预料到,这对 SFML SpriteTexture 将用于显示和保存来自图形资产文件夹的背景图形。

  • m_Playing:这个布尔值将让游戏引擎知道关卡是否已经开始(通过按下Enter键)。一旦玩家开始游戏,他们就没有暂停游戏的选项。

  • m_Character1:当屏幕全屏时,它应该居中显示 Thomas(m_Character1 = true),还是 Bob(m_Character1 = false)?最初,它被初始化为 true,以便居中显示 Thomas。

  • m_SplitScreen:游戏当前是否以分屏模式进行?我们将使用这个变量来决定如何使用我们之前声明的所有 View 对象。

  • m_TimeRemaining 变量:这个 float 变量保存当前关卡剩余的时间。在之前的代码中,为了测试目的,它被设置为10,直到我们真正为每个关卡设置特定的时间。

  • m_GameTimeTotal 变量:这个变量是一个 SFML Time 对象。它跟踪游戏已经进行了多长时间。

  • m_NewLevelRequired布尔变量:此变量检查玩家是否刚刚完成或失败了一个级别。然后我们可以使用它来触发加载下一个级别或重新启动当前级别。

  • input函数:此函数将处理玩家的所有输入,这在本游戏中完全来自键盘。乍一看,似乎它直接处理所有键盘输入。然而,在这个游戏中,我们将直接处理影响ThomasBob类中的 Thomas 或 Bob 的键盘输入。我们将调用input函数,而这个函数将直接处理键盘输入,例如退出、切换到分屏以及其他任何键盘输入。

  • update函数:此函数将执行我们以前在main函数的更新部分中执行的所有工作。我们还将从update函数中调用一些其他函数,以便保持代码组织良好。如果您回顾代码,您将看到它接收一个float参数,该参数将保存自上一帧以来经过的秒数的分数。当然,这正是我们需要更新所有游戏对象的内容。

  • draw函数:此函数将包含以前项目的主函数中绘图部分中的所有代码。然而,当我们学习使用 SFML 进行其他绘图方式时,将有一些绘图代码不在此函数中。当我们学习第十六章中的粒子系统时,我们将看到这些新代码,扩展 SFML 类、粒子系统和着色器

现在让我们逐一运行所有公共函数:

  • Engine构造函数:正如我们所期望的那样,当我们首次声明Engine的实例时,将调用此函数。它将对类进行所有设置和初始化。我们将很快在编写Engine.cpp文件时看到确切的情况。

  • run函数:这是我们需要调用的唯一公共函数。它将触发输入、更新和绘制的执行,这将完成所有工作。

接下来,我们将看到所有这些函数的定义以及一些变量的作用。

编写 Engine.cpp

在我们之前的所有类中,我们将所有函数定义放入.cpp文件中,并以类名为前缀。由于我们这个项目的目标是使代码更易管理,我们正在以稍有不同的方式进行操作。

Engine.cpp文件中,我们将放置构造函数(Engine)和公共run函数。所有其他函数将放在自己的.cpp文件中,文件名清楚地说明了哪个函数放在哪里。只要我们在包含Engine类函数定义的所有文件顶部添加适当的包含指令(#include "Engine.h"),这对编译器来说不会是问题。

让我们开始编写Engine并在Engine.cpp中运行它。在解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp),然后在名称字段中键入Engine.cpp。最后,单击添加按钮。现在我们准备好为Engine类编写.cpp文件。

编写 Engine 类构造函数定义

此函数的代码将放在我们最近创建的Engine.cpp文件中。

添加以下代码,然后我们可以讨论它:

#include "stdafx.h" 
#include "Engine.h" 

Engine::Engine() 
{ 
   // Get the screen resolution  
   // and create an SFML window and View 
   Vector2f resolution; 
   resolution.x = VideoMode::getDesktopMode().width; 
   resolution.y = VideoMode::getDesktopMode().height; 

   m_Window.create(VideoMode(resolution.x, resolution.y), 
      "Thomas was late", 
      Style::Fullscreen); 

   // Initialize the fullscreen view 
   m_MainView.setSize(resolution); 
   m_HudView.reset( 
      FloatRect(0, 0, resolution.x, resolution.y)); 

   // Inititialize the split screen Views 
   m_LeftView.setViewport( 
      FloatRect(0.001f, 0.001f, 0.498f, 0.998f)); 

   m_RightView.setViewport( 
      FloatRect(0.5f, 0.001f, 0.499f, 0.998f)); 

   m_BGLeftView.setViewport( 
      FloatRect(0.001f, 0.001f, 0.498f, 0.998f)); 

   m_BGRightView.setViewport( 
      FloatRect(0.5f, 0.001f, 0.499f, 0.998f)); 

   m_BackgroundTexture = TextureHolder::GetTexture( 
      "graphics/background.png"); 

   // Associate the sprite with the texture 
   m_BackgroundSprite.setTexture(m_BackgroundTexture); 

} 

我们之前见过很多代码。例如,有通常的代码行来获取屏幕分辨率以及创建RenderWindow。在前面的代码末尾,我们使用了现在熟悉的代码来加载纹理并将其分配给 Sprite。在这种情况下,我们正在加载background.png纹理并将其分配给m_BackgroundSprite

在四次调用setViewport函数之间的代码需要一些解释。setViewport函数将屏幕的一部分分配给 SFMLView对象。但它不使用像素坐标,而是使用比例。其中“1”是整个屏幕(宽度或高度),每次调用setViewport的前两个值是起始位置(水平,然后垂直),最后两个值是结束位置。

注意,m_LeftViewm_BGLeftView放置在完全相同的位置,从屏幕的几乎最左边(0.001)开始,到离中心的两千分之一(0.498)结束。

m_RightViewm_BGRightView也处于完全相同的位置,从前两个View对象的左侧开始(0.5),延伸到屏幕的几乎最右侧(0.998)。

此外,所有视图在屏幕的顶部和底部留下一小片空隙。当我们在白色背景上绘制这些View对象时,它将产生在屏幕的两侧之间有一条细白线以及边缘周围有一条细白边框的效果。

我已经尝试在以下图表中表示这种效果:

编写引擎类构造函数定义

理解它的最佳方法是完成本章,运行代码,并看到它的实际效果。

编写运行函数定义

此函数的代码将放在我们最近创建的Engine.cpp文件中。

在上一个构造函数代码之后立即添加以下代码:

void Engine::run() 
{ 
   // Timing    
   Clock clock; 

   while (m_Window.isOpen()) 
   { 
      Time dt = clock.restart(); 
      // Update the total game time 
      m_GameTimeTotal += dt; 
      // Make a decimal fraction from the delta time 
      float dtAsSeconds = dt.asSeconds(); 

      // Call each part of the game loop in turn 
      input(); 
      update(dtAsSeconds); 
      draw(); 
   } 
} 

运行函数是我们引擎的中心-它启动所有其他部分。首先,我们声明一个 Clock 对象。接下来,我们有熟悉的while(window.isOpen())循环,它创建游戏循环。在这个 while 循环内,我们做以下事情:

  1. 重新启动clock并保存上一个循环中所花费的时间dt

  2. m_GameTimeTotal中跟踪总经过时间。

  3. 声明并初始化一个float来表示上一帧经过的秒数。

  4. 调用input

  5. 调用update并传入经过的时间(dtAsSeconds)。

  6. 调用draw

所有这些都应该看起来非常熟悉。新的是它包含在run函数中。

编写输入函数定义

如前所述,此函数的代码将放在自己的文件中,因为它比构造函数或run函数更加复杂。我们将使用#include "Engine.h"并在函数签名前加上Engine::以确保编译器知道我们的意图。

解决方案资源管理器中右键单击源文件,然后选择添加 | 新项目...。在添加新项目窗口中,通过左键单击突出显示(单击)C++文件(.cpp,然后在名称字段中键入Input.cpp。最后,单击添加按钮。现在我们准备编写input函数。

添加以下代码:

void Engine::input() 
{ 
   Event event; 
   while (m_Window.pollEvent(event)) 
   { 
      if (event.type == Event::KeyPressed) 
      {         
         // Handle the player quitting 
         if (Keyboard::isKeyPressed(Keyboard::Escape)) 
         { 
            m_Window.close(); 
         } 

         // Handle the player starting the game 
         if (Keyboard::isKeyPressed(Keyboard::Return)) 
         { 
            m_Playing = true; 
         } 

         // Switch between Thomas and Bob 
         if (Keyboard::isKeyPressed(Keyboard::Q)) 
         { 
            m_Character1 = !m_Character1; 
         } 

         // Switch between full and split screen 
         if (Keyboard::isKeyPressed(Keyboard::E)) 
         { 
            m_SplitScreen = !m_SplitScreen; 
         } 
      } 
   }   
} 

与之前的两个项目一样,我们每帧都会检查RenderWindow事件队列。同样,我们之前所做的一样,使用if (Keyboard::isKeyPressed(Keyboard::E))来检测特定的键。我们刚刚添加的代码中最重要的是这些键实际上做了什么:

  • 通常,Esc键关闭窗口,游戏将退出。

  • Enter键将m_Playing设置为 true,最终,这将导致开始关卡。

  • Q键在全屏模式下在m_Character1的值之间切换truefalse。它将在主View的 Thomas 和 Bob 之间切换。

  • E键在m_SplitScreen之间切换truefalse。这将导致在全屏和分屏视图之间切换。

大部分键盘功能将在本章结束时完全可用。我们即将能够运行我们的游戏引擎。接下来,让我们编写update函数。

编写更新函数定义

如前所述,此函数的代码将放在自己的文件中,因为它比构造函数或run函数更加复杂。我们将使用#include "Engine.h"并在函数签名前加上Engine::以确保编译器了解我们的意图。

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项目...。在添加新项目窗口中,通过左键单击选择C++文件(.cpp ,然后在名称字段中键入Update.cpp。最后,单击添加按钮。现在我们准备为update函数编写一些代码。

将以下代码添加到Update.cpp文件中以实现update函数:

#include "stdafx.h" 
#include "Engine.h" 
#include <SFML/Graphics.hpp> 
#include <sstream> 

using namespace sf; 

void Engine::update(float dtAsSeconds) 
{ 

   if (m_Playing) 
   { 
      // Count down the time the player has left 
      m_TimeRemaining -= dtAsSeconds; 

      // Have Thomas and Bob run out of time? 
      if (m_TimeRemaining <= 0) 
      { 
         m_NewLevelRequired = true; 
      } 

   }// End if playing 

} 

首先要注意的是,update函数接收上一帧所用时间作为参数。这当然对于update函数履行其职责至关重要。

在这个阶段,上述代码并没有实现任何可见的效果。它为我们将来的章节提供了所需的结构。它从m_TimeRemaining中减去了上一帧所用的时间。它检查时间是否已经用完,如果是,则将m_NewLevelRequired设置为true。所有这些代码都包裹在一个if语句中,只有当m_Playingtrue时才执行。原因是,与以前的项目一样,我们不希望在游戏尚未开始时时间推移和对象更新。

随着项目的继续,我们将在此基础上构建代码。

编写绘制函数定义

如前所述,此函数的代码将放在自己的文件中,因为它比构造函数或run函数更加复杂。我们将使用#include "Engine.h"并在函数签名前加上Engine::以确保编译器了解我们的意图。

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项目...。在添加新项目窗口中,通过左键单击选择C++文件(.cpp ,然后在名称字段中键入Draw.cpp。最后,单击添加按钮。现在我们准备为draw函数添加一些代码。

将以下代码添加到Draw.cpp文件中以实现draw函数:

#include "stdafx.h" 
#include "Engine.h" 

void Engine::draw() 
{ 
   // Rub out the last frame 
   m_Window.clear(Color::White); 

   if (!m_SplitScreen) 
   { 
      // Switch to background view 
      m_Window.setView(m_BGMainView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_MainView 
      m_Window.setView(m_MainView);     
   } 
   else 
   { 
      // Split screen view is active 

      // First draw Thomas' side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGLeftView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_LeftView 
      m_Window.setView(m_LeftView); 

      // Now draw Bob's side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGRightView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_RightView 
      m_Window.setView(m_RightView); 

   } 

   // Draw the HUD 
   // Switch to m_HudView 
   m_Window.setView(m_HudView); 

   // Show everything we have just drawn 
   m_Window.display(); 
} 

在上述代码中,没有什么是我们以前没有见过的。代码通常从清除屏幕开始。在这个项目中,我们用白色清除屏幕。新的是不同的绘制选项是如何通过条件分隔的,检查屏幕当前是分割还是全屏:

if (!m_SplitScreen) 
{ 
} 
else 
{ 
} 

如果屏幕没有分割,我们在背景Viewm_BGView)中绘制背景精灵,然后切换到主全屏Viewm_MainView)。请注意,目前我们实际上并没有在m_MainView中进行任何绘制。

另一方面,如果屏幕被分割,将执行else块中的代码,并在屏幕左侧绘制m_BGLeftView上的背景精灵,然后切换到m_LeftView

然后,在else块中,我们绘制m_BGRightView上的背景精灵,然后切换到m_RightView

在刚才描述的if...else结构之外,我们切换到m_HUDView。在这个阶段,我们实际上并没有在m_HUDView中绘制任何东西。

与另外两个(inputupdate)最重要的函数一样,我们经常会回到draw函数。我们将添加需要绘制的游戏新元素。您会注意到,每次我们这样做时,我们都会在主要、左侧和右侧的每个部分中添加代码。

让我们快速回顾一下Engine类,然后我们可以启动它。

到目前为止的 Engine 类

我们所做的是将以前在main函数中的所有代码抽象成inputupdatedraw函数。这些函数的连续循环以及时间控制由run函数处理。

考虑在 Visual Studio 中保持Input.cppUpdate.cppDraw.cpp标签页打开,可能按顺序组织,如下截图所示:

到目前为止的 Engine 类

在项目的过程中,我们将重新审视这些函数,添加更多的代码。现在我们已经有了Engine类的基本结构和功能,我们可以在main函数中创建一个实例,并看到它的运行情况。

编写主函数

让我们将HelloSFML.cpp文件重命名为Main.cpp。在解决方案资源管理器中右键单击HelloSFML文件,然后选择重命名。将名称更改为Main.cpp。这将是包含我们的main函数和实例化Engine类的代码的文件。

将以下代码添加到Main.cpp中:

#include "stdafx.h" 
#include "Engine.h" 

int main() 
{ 
   // Declare an instance of Engine 
   Engine engine; 

   // Start the engine VRRrrrrmmm 
   engine.run(); 

   // Quit in the usual way when the engine is stopped 
   return 0; 
} 

我们所做的就是为Engine类添加一个包含指令,声明一个Engine的实例,然后调用它的run函数。直到玩家退出并且执行返回到mainreturn 0语句之前,所有的事情都将由Engine类处理。

这很容易。现在我们可以运行游戏,看到空的背景,无论是全屏还是分屏,最终将包含所有动作。

到目前为止,游戏是全屏模式,只显示背景:

编写主函数

现在按下E键,您将能够看到屏幕被整齐地分成两半,准备进行分屏合作游戏:

编写主函数

以下是一些可能会让您感到困惑的问题。

常见问题解答

  1. 我并不完全理解代码文件的结构。

  2. 抽象确实可以使我们的代码结构变得不太清晰,但实际的代码本身变得更容易。与我们在以前的项目中将所有内容塞进主函数不同,我们将把代码分成Input.cppUpdate.cppDraw.cpp。此外,随着我们的进行,我们将使用更多的类来将相关代码分组。再次研究《构建 Thomas Was Late 代码》部分,特别是图表。

总结

在本章中,我们介绍了 Thomas Was Late 游戏,并为项目的其余部分奠定了理解和代码结构的基础。解决方案资源管理器中确实有很多文件,但只要我们理解每个文件的目的,我们会发现项目的实现变得更加容易。

在接下来的章节中,我们将学习另外两个基本的 C++主题,继承和多态。我们还将开始利用它们,构建三个类来代表两个可玩角色。

第十三章:高级 OOP-继承和多态

在本章中,我们将通过学习稍微更高级的继承多态概念来进一步扩展我们对 OOP 的知识。然后,我们将能够使用这些新知识来实现我们游戏的明星角色,Thomas 和 Bob。在本章中,我们将更详细地介绍以下内容:

  • 如何使用继承扩展和修改类?

  • 通过多态将一个类的对象视为多种类型的类

  • 抽象类以及设计永远不会实例化的类实际上可以很有用

  • 构建一个抽象的PlayableCharacter

  • 使用ThomasBob类来实现继承

  • 将 Thomas 和 Bob 添加到游戏项目中

继承

我们已经看到了如何通过实例化/创建对象来使用 SFML 库的类的其他人的辛勤工作。但是,这整个面向对象的东西甚至比那更深入。

如果有一个类中有大量有用的功能,但不完全符合我们的要求怎么办?在这种情况下,我们可以继承自其他类。就像它听起来的那样,继承意味着我们可以利用其他人的类的所有特性和好处,包括封装,同时进一步完善或扩展代码,使其特别适合我们的情况。在这个项目中,我们将继承并扩展一些 SFML 类。我们也会用我们自己的类来做同样的事情。

让我们看一些使用继承的代码,

扩展一个类

考虑到所有这些,让我们看一个示例类,并看看我们如何扩展它,只是为了看看语法并作为第一步。

首先,我们定义一个要继承的类。这与我们创建任何其他类没有什么不同。看一下这个假设的Soldier类声明:

class Soldier 
{ 
   private: 
      // How much damage can the soldier take 
      int m_Health; 
      int m_Armour; 
      int m_Range; 
      int m_ShotPower; 

   Public: 
      void setHealth(int h); 
      void setArmour(int a);   
      void setRange(int r); 
      void setShotPower(int p); 
}; 

在前面的代码中,我们定义了一个Soldier类。它有四个私有变量,m_Healthm_Armourm_Rangem_ShotPower。它有四个公共函数setHealthsetArmoursetRangesetShotPower。我们不需要看到函数的定义,它们只是简单地初始化它们的名字明显的适当变量。

我们还可以想象,一个完全实现的Soldier类会比这更加深入。它可能有函数,比如shootgoProne等。如果我们在一个 SFML 项目中实现了Soldier类,它可能会有一个Sprite对象,以及一个update和一个getPostion函数。

这里呈现的简单场景适合学习继承。现在让我们看看一些新东西,实际上是从Soldier类继承。看看这段代码,特别是突出显示的部分:

class Sniper : public Soldier 
{ 
public: 
   // A constructor specific to Sniper 
   Sniper::Sniper(); 
}; 

通过将: public Soldier代码添加到Sniper类声明中,Sniper继承自Soldier。但这到底意味着什么?Sniper是一个Soldier。它拥有Soldier的所有变量和函数。然而,继承不仅仅是这样。

还要注意,在前面的代码中,我们声明了一个Sniper构造函数。这个构造函数是Sniper独有的。我们不仅继承了Soldier,还扩展了SoldierSoldier类的所有功能(定义)都由Soldier类处理,但Sniper构造函数的定义必须由Sniper类处理。

这是假设的Sniper构造函数定义可能是这样的:

// In Sniper.cpp 
Sniper::Sniper() 
{ 
   setHealth(10); 
   setArmour(10);  
   setRange(1000); 
   setShotPower(100); 
} 

我们可以继续编写一堆其他类,这些类是Soldier类的扩展,也许是CommandoInfantryman。每个类都有完全相同的变量和函数,但每个类也可以有一个独特的构造函数,用于初始化适合Soldier类型的变量。Commando可能有非常高的m_Healthm_ShotPower,但是m_Range非常小。Infantryman可能介于CommandoSniper之间,每个变量的值都是中等水平。

提示

好像面向对象编程已经足够有用了,现在我们可以模拟现实世界的对象,包括它们的层次结构。我们通过子类化、扩展和继承其他类来实现这一点。

我们可能想要学习的术语是从中扩展的类是超类,从超类继承的类是子类。我们也可以说类和类。

提示

关于继承,您可能会问这样一个问题:为什么?原因是这样的:我们可以编写一次通用代码;在父类中,我们可以更新该通用代码,所有继承自它的类也会被更新。此外,子类只能使用公共和受保护实例变量和函数。因此,如果设计得当,这也进一步增强了封装的目标。

你说受保护?是的。有一个称为受保护的类变量和函数的访问限定符。您可以将受保护的变量视为介于公共和私有之间。以下是访问限定符的快速摘要,以及有关受保护限定符的更多详细信息:

  • 公共变量和函数可以被任何人访问和使用。

  • 私有变量和函数只能被类的内部代码访问/使用。这对封装很有用,当我们需要访问/更改私有变量时,我们可以提供公共的gettersetter函数(如getSprite等)。如果我们扩展了一个具有私有变量和函数的类,那么子类不能直接访问其父类的私有数据。

  • 受保护变量和函数几乎与私有变量和函数相同。它们不能被类的实例直接访问/使用。但是,它们可以被扩展它们所声明的类的任何类直接使用。因此,它们就像是私有的,只不过对子类是可见的。

要充分理解受保护的变量和函数以及它们如何有用,让我们先看看另一个主题,然后我们可以看到它们的作用。

多态

多态允许我们编写的代码不那么依赖于我们试图操作的类型。这可以使我们的代码更清晰和更高效。多态意味着不同的形式。如果我们编写的对象可以是多种类型的东西,那么我们就可以利用这一点。

注意

多态对我们意味着什么?简而言之,多态就是:任何子类都可以作为使用超类的代码的一部分。这意味着我们可以编写更简单、更易于理解的代码,也更容易修改或更改。此外,我们可以为超类编写代码,并依赖于这样一个事实:在一定的参数范围内,无论它被子类化多少次,代码仍然可以正常工作。

让我们讨论一个例子。

假设我们想利用多态来帮助编写一个动物园管理游戏,我们需要喂养和照顾动物的需求。我们可能会想要有一个名为feed的函数。我们可能还想将要喂养的动物的实例传递给feed函数。

当然,动物园有很多种类的动物——狮子大象三趾树懒。有了我们对 C++继承的新知识,编写一个Animal类并让所有不同类型的动物从中继承就会有意义。

如果我们想编写一个函数(feed),我们可以将狮子、大象和三趾树懒作为参数传递进去,似乎需要为每种类型的Animal编写一个feed函数。但是,我们可以编写多态函数,具有多态返回类型和参数。看一下这个假设的feed函数的定义:

void feed(Animal& a) 
{ 
   a.decreaseHunger(); 
} 

前面的函数将Animal引用作为参数,这意味着可以将任何从扩展Animal的类构建的对象传递给它。

因此,今天你甚至可以编写代码,然后在一周、一个月或一年后创建另一个子类,相同的函数和数据结构仍然可以工作。此外,我们可以对子类强制执行一组规则,规定它们可以做什么,不能做什么,以及如何做。因此,一个阶段的良好设计可以影响其他阶段。

但我们真的会想要实例化一个真正的动物吗?

抽象类 - 虚拟和纯虚拟函数

抽象类是一个不能被实例化的类,因此不能被制作成对象。

提示

在这里我们可能想学习的一些术语是具体类。具体类是任何不是抽象的类。换句话说,到目前为止我们编写的所有类都是具体类,可以实例化为可用的对象。

那么,这段代码永远不会被使用了吗?但这就像付钱给一个建筑师设计你的房子,然后永远不建造它!

如果我们或一个类的设计者想要强制其用户在使用他们的类之前继承它,他们可以将一个类抽象化。然后,我们就不能从中创建一个对象;因此,我们必须首先扩展它,然后从子类创建一个对象。

为此,我们可以创建一个纯虚拟函数并不提供任何定义。然后,任何扩展它的类都必须覆盖(重新编写)该函数。

让我们看一个例子;这会有所帮助。我们通过添加一个纯虚拟函数使一个类变成抽象类,比如这个只能执行通用动作makeNoise的抽象Animal类:

Class Animal 
   private: 
      // Private stuff here 

   public: 

      void virtual makeNoise() = 0; 

      // More public stuff here 
}; 

如你所见,我们在函数声明之前添加了 C++关键字virtual,之后添加了= 0。现在,任何扩展/继承自Animal的类都必须覆盖makeNoise函数。这是有道理的,因为不同类型的动物发出的声音非常不同。也许我们可以假设任何扩展Animal类的人都足够聪明,能够注意到Animal类不能发出声音,他们需要处理它,但如果他们没有注意到呢?关键是通过创建一个纯虚拟函数,我们保证他们会注意到,因为他们必须注意到。

抽象类也很有用,因为有时我们需要一个可以用作多态类型的类,但需要保证它永远不能用作对象。例如,Animal单独使用并没有太多意义。我们不谈论动物;我们谈论动物的类型。我们不会说,“哦,看那只可爱的、蓬松的、白色的动物!”或者,“昨天我们去宠物店买了一只动物和一个动物床”。这太抽象了。

因此,抽象类有点像一个模板,可以被任何扩展它的类使用(继承自它)。如果我们正在构建一个工业帝国类型的游戏,玩家管理企业和员工,我们可能需要一个Worker类,并将其扩展为MinerSteelworkerOfficeWorker,当然还有Programmer。但是一个普通的Worker到底是做什么的呢?我们为什么要实例化一个?

答案是我们不想实例化一个,但我们可能想将其用作多态类型,以便在函数之间传递多个Worker子类,并且有可以容纳所有类型的工人的数据结构。

所有纯虚拟函数必须被扩展父类的任何类覆盖,该父类包含纯虚拟函数。这意味着抽象类可以提供一些在所有子类中都可用的公共功能。例如,Worker类可能有m_AnnualSalarym_Productivitym_Age成员变量。它可能还有getPayCheck函数,这不是纯虚拟的,并且在所有子类中都是相同的,但它可能有一个doWork函数,这是纯虚拟的,必须被覆盖,因为所有不同类型的Worker都会以非常不同的方式doWork

注意

顺便说一句,virtual与纯虚函数相反,是一个可选重写的函数。你声明一个虚函数的方式与声明纯虚函数的方式相同,但是最后不加上= 0。在当前的游戏项目中,我们将使用一个纯虚函数。

如果对虚拟、纯虚拟或抽象的任何内容不清楚,使用它可能是理解它的最佳方式。

构建 PlayableCharacter 类

现在我们已经了解了继承、多态和纯虚函数的基础知识,我们将把它们应用起来。我们将构建一个PlayableCharacter类,它将拥有我们游戏中任何角色大部分功能所需的功能。它将有一个纯虚函数,handleInputhandleInput函数在子类中需要有很大的不同,所以这是有道理的。

由于PlayableCharacter将有一个纯虚函数,它将是一个抽象类,不可能有它的对象。然后我们将构建ThomasBob类,它们将继承自PlayableCharacter,实现纯虚函数的定义,并允许我们在游戏中实例化BobThomas对象。

编写 PlayableCharacter.h

通常,在创建一个类时,我们将从包含成员变量和函数声明的头文件开始。新的是,在这个类中,我们将声明一些protected成员变量。请记住,受保护的变量可以被继承自具有受保护变量的类的类使用,就好像它们是Public一样。

解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件( .h 突出显示,然后在名称字段中键入PlayableCharacter.h。最后,单击添加按钮。我们现在准备为PlayableCharacter类编写头文件。

我们将在三个部分中添加和讨论PlayableCharacter.h文件的内容。首先是protected部分,然后是private,最后是public

PlayableCharacter.h文件旁边添加下面显示的代码:

#pragma once 
#include <SFML/Graphics.hpp> 

using namespace sf; 

class PlayableCharacter 
{ 
protected: 
   // Of course we will need a sprite 
   Sprite m_Sprite; 

   // How long does a jump last 
   float m_JumpDuration; 

   // Is character currently jumping or falling 
   bool m_IsJumping; 
   bool m_IsFalling; 

   // Which directions is the character currently moving in 
   bool m_LeftPressed; 
   bool m_RightPressed; 

   // How long has this jump lasted so far 
   float m_TimeThisJump; 

   // Has the player just initialted a jump 
   bool m_JustJumped = false; 

   // Private variables and functions come next 

我们刚刚编写的代码中要注意的第一件事是所有变量都是protected的。这意味着当我们扩展类时,我们刚刚编写的所有变量将对扩展它的类可访问。我们将用ThomasBob类扩展这个类。

除了protected访问规范之外,先前的代码没有什么新的或复杂的。然而,值得注意的是一些细节。然后随着我们的进展,理解类的工作原理将变得容易。因此,让我们逐个运行这些protected变量。

我们有一个相对可预测的Spritem_Sprite。我们有一个名为m_JumpDuration的浮点数,它将保存代表角色能够跳跃的时间值。数值越大,角色就能够跳得越远/高。

接下来,我们有一个布尔值,m_IsJumping,当角色跳跃时为true,否则为false。这将有助于确保角色在空中时无法跳跃。

m_IsFalling变量与m_IsJumping有类似的用途。它将有助于知道角色何时下落。

接下来,我们有两个布尔值,如果角色的左或右键盘按钮当前被按下,则为true。这取决于角色(AD为 Thomas,左右箭头键为 Bob)。我们将在ThomasBob类中看到如何响应这些布尔值。

m_TimeThisJump浮点变量在每一帧m_IsJumpingtrue时更新。然后我们就可以知道m_JumpDuration何时被达到。

最后一个protected变量是布尔值m_JustJumped。如果在当前帧中启动了跳跃,它将为true。这对于知道何时播放跳跃音效将很有用。

接下来,将以下private变量添加到PlayableCharacter.h文件中:

private: 
   // What is the gravity 
   float m_Gravity; 

   // How fast is the character 
   float m_Speed = 400; 

   // Where is the player 
   Vector2f m_Position; 

   // Where are the characters various body parts? 
   FloatRect m_Feet; 
   FloatRect m_Head; 
   FloatRect m_Right; 
   FloatRect m_Left; 

   // And a texture 
   Texture m_Texture; 

   // All our public functions will come next 

在之前的代码中,我们有一些有趣的private变量。请记住,这些变量只能被PlayableCharacter类中的代码直接访问。ThomasBob类将无法直接访问它们。

m_Gravity变量将保存角色下落的每秒像素数。m_Speed变量将保存角色每秒可以向左或向右移动的像素数。

Vector2fm_Position变量是角色在世界中(而不是屏幕上)的位置,即角色的中心位置。

接下来的四个FloatRect对象很重要。在Zombie Arena游戏中进行碰撞检测时,我们只是检查两个FloatRect对象是否相交。每个FloatRect对象代表整个角色、拾取物或子弹。对于非矩形形状的对象(僵尸和玩家),这有点不准确。

在这个游戏中,我们需要更加精确。m_Feetm_Headm_Rightm_Left FloatRect对象将保存角色身体不同部位的坐标。这些坐标将在每一帧中更新。

通过这些坐标,我们将能够准确地判断角色何时落在平台上,跳跃时是否碰到头部,或者与侧面的瓷砖擦肩而过。

最后,我们有TextureTextureprivate的,因为它不会被ThomasBob类直接使用,但正如我们所看到的,Spriteprotected的,因为它被直接使用。

现在将所有public函数添加到PlayableCharacter.h文件中,然后我们将讨论它们:

public: 

   void spawn(Vector2f startPosition, float gravity); 

   // This is a pure virtual function 
   bool virtual handleInput() = 0; 
   // This class is now abstract and cannot be instanciated 

   // Where is the player 
   FloatRect getPosition(); 

   // A rectangle representing the position  
   // of different parts of the sprite 
   FloatRect getFeet(); 
   FloatRect getHead(); 
   FloatRect getRight(); 
   FloatRect getLeft(); 

   // Send a copy of the sprite to main 
   Sprite getSprite(); 

   // Make the character stand firm 
   void stopFalling(float position); 
   void stopRight(float position); 
   void stopLeft(float position); 
   void stopJump(); 

   // Where is the center of the character 
   Vector2f getCenter(); 

   // We will call this function once every frame 
   void update(float elapsedTime); 

};// End of the class 

让我们谈谈我们刚刚添加的每个函数声明。这将使编写它们的定义更容易跟踪。

  • spawn函数接收一个名为startPositionVector2f和一个名为gravityfloat。顾名思义,startPosition将是角色在关卡中开始的坐标,gravity将是角色下落的每秒像素数。

  • bool virtual handleInput() = 0当然是我们的纯虚函数。由于PlayableCharacter有这个函数,任何扩展它的类,如果我们想要实例化它,必须为这个函数提供定义。因此,当我们在一分钟内为PlayableCharacter编写所有函数定义时,我们将不为handleInput提供定义。当然,ThomasBob类中也需要有定义。

  • getPosition函数返回一个代表整个角色位置的FloatRect

  • getFeet()函数,以及getHeadgetRightgetLeft,每个都返回一个代表角色身体特定部位位置的FloatRect。这正是我们需要进行详细的碰撞检测。

  • getSprite函数像往常一样,将m_Sprite的副本返回给调用代码。

  • stopFallingstopRightstopLeftstopJump函数接收一个float值,函数将使用它来重新定位角色并阻止它在实心瓷砖上行走或跳跃。

  • getCenter函数将一个Vector2f返回给调用代码,让它准确地知道角色的中心在哪里。这个值当然保存在m_Position中。我们将在后面看到,它被Engine类用来围绕适当的角色中心适当地居中适当的View

  • 我们之前多次见过的update函数和往常一样,它接受一个float参数,表示当前帧所花费的秒数的一部分。然而,这个update函数需要做的工作比以前的update函数(来自其他项目)更多。它需要处理跳跃,以及更新代表头部、脚部、左侧和右侧的FloatRect对象。

现在我们可以为所有函数编写定义,当然,除了handleInput

编写 PlayableCharacter.cpp

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp,然后在名称字段中键入PlayableCharacter.cpp。最后,单击添加按钮。现在我们准备为PlayableCharacter类编写.cpp文件。

我们将把代码和讨论分成几个部分。首先,添加包含指令和spawn函数的定义:

#include "stdafx.h" 
#include "PlayableCharacter.h" 

void PlayableCharacter::spawn(Vector2f startPosition, float gravity) 
{ 
   // Place the player at the starting point 
   m_Position.x = startPosition.x; 
   m_Position.y = startPosition.y; 

   // Initialize the gravity 
   m_Gravity = gravity; 

   // Move the sprite in to position 
   m_Sprite.setPosition(m_Position); 

} 

spawn函数使用传入的位置初始化m_Position,并初始化m_Gravity。代码的最后一行将m_Sprite移动到其起始位置。

接下来,在前面的代码之后立即添加update函数的定义:

void PlayableCharacter::update(float elapsedTime) 
{ 

   if (m_RightPressed) 
   { 
      m_Position.x += m_Speed * elapsedTime; 
   } 

   if (m_LeftPressed) 
   { 
      m_Position.x -= m_Speed * elapsedTime; 
   } 

   // Handle Jumping 
   if (m_IsJumping) 
   { 
      // Update how long the jump has been going 
      m_TimeThisJump += elapsedTime; 

      // Is the jump going upwards 
      if (m_TimeThisJump < m_JumpDuration) 
      { 
         // Move up at twice gravity 
         m_Position.y -= m_Gravity * 2 * elapsedTime; 
      } 
      else 
      { 
         m_IsJumping = false; 
         m_IsFalling = true; 
      } 

   } 

   // Apply gravity 
   if (m_IsFalling) 
   { 
      m_Position.y += m_Gravity * elapsedTime; 
   } 

   // Update the rect for all body parts 
   FloatRect r = getPosition(); 

   // Feet 
   m_Feet.left = r.left + 3; 
   m_Feet.top = r.top + r.height - 1; 
   m_Feet.width = r.width - 6; 
   m_Feet.height = 1; 

   // Head 
   m_Head.left = r.left; 
   m_Head.top = r.top + (r.height * .3); 
   m_Head.width = r.width; 
   m_Head.height = 1; 

   // Right 
   m_Right.left = r.left + r.width - 2; 
   m_Right.top = r.top + r.height * .35; 
   m_Right.width = 1; 
   m_Right.height = r.height * .3; 

   // Left 
   m_Left.left = r.left; 
   m_Left.top = r.top + r.height * .5; 
   m_Left.width = 1; 
   m_Left.height = r.height * .3; 

   // Move the sprite into position 
   m_Sprite.setPosition(m_Position); 

} 

代码的前两部分检查m_RightPressedm_LeftPressed是否为true。如果其中任何一个是,m_Position将使用与上一个项目相同的公式(经过的时间乘以速度)进行更改。

接下来,我们看看角色当前是否正在执行跳跃。我们从if(m_IsJumping)知道这一点。如果这个if语句为true,代码将执行以下步骤:

  1. elapsedTime更新m_TimeThisJump

  2. 检查m_TimeThisJump是否仍然小于m_JumpDuration。如果是,则通过重力乘以经过的时间两倍来改变m_Position的 y 坐标。

  3. else子句中,当m_TimeThisJump不低于m_JumpDuration时,m_Falling被设置为true。这样做的效果将在下面看到。此外,m_Jumping被设置为false。这样做是为了防止我们刚刚讨论的代码执行,因为if(m_IsJumping)现在为 false。

if(m_IsFalling)块每帧将m_Position向下移动。它使用m_Gravity的当前值和经过的时间进行移动。

以下代码(几乎所有剩余的代码)相对于精灵的当前位置更新角色的身体部位。看一下下面的图表,看看代码如何计算角色的虚拟头部、脚部、左侧和右侧的位置:

编写 PlayableCharacter.cpp

代码的最后一行使用setPosition函数将精灵移动到update函数的所有可能性之后的正确位置。

现在立即在上一个代码之后添加getPositiongetCentergetFeetgetHeadgetLeftgetRightgetSprite函数的定义:

FloatRect PlayableCharacter::getPosition() 
{ 
   return m_Sprite.getGlobalBounds(); 
} 

Vector2f PlayableCharacter::getCenter() 
{ 
   return Vector2f( 
      m_Position.x + m_Sprite.getGlobalBounds().width / 2, 
      m_Position.y + m_Sprite.getGlobalBounds().height / 2 
      ); 
} 

FloatRect PlayableCharacter::getFeet() 
{ 
   return m_Feet; 
} 

FloatRect PlayableCharacter::getHead() 
{ 
   return m_Head; 
} 

FloatRect PlayableCharacter::getLeft() 
{ 
   return m_Left; 
} 

FloatRect PlayableCharacter::getRight() 
{ 
   return m_Right; 
} 

Sprite PlayableCharacter::getSprite() 
{ 
   return m_Sprite; 
} 

getPosition函数返回包装整个精灵的FloatRectgetCenter返回一个包含精灵中心的Vector2f。请注意,我们将精灵的高度和宽度除以二,以便动态地得到这个结果。这是因为 Thomas 和 Bob 的身高不同。

getFeetgetHeadgetLeftgetRight函数返回表示角色各个身体部位的FloatRect对象,我们在update函数中每帧更新它们。我们将在下一章中编写使用这些函数的碰撞检测代码

getSprite函数像往常一样返回m_Sprite的副本。

最后,对于PlayableCharacter类,立即在上一个代码之后添加stopFallingstopRightstopLeftstopJump函数的定义:

void PlayableCharacter::stopFalling(float position) 
{ 
   m_Position.y = position - getPosition().height; 
   m_Sprite.setPosition(m_Position); 
   m_IsFalling = false; 
} 

void PlayableCharacter::stopRight(float position) 
{ 

   m_Position.x = position - m_Sprite.getGlobalBounds().width; 
   m_Sprite.setPosition(m_Position); 
} 

void PlayableCharacter::stopLeft(float position) 
{ 
   m_Position.x = position + m_Sprite.getGlobalBounds().width; 
   m_Sprite.setPosition(m_Position); 
} 

void PlayableCharacter::stopJump() 
{ 
   // Stop a jump early  
   m_IsJumping = false; 
   m_IsFalling = true; 
} 

每个前面的函数都接收一个值作为参数,用于重新定位精灵的顶部、底部、左侧或右侧。这些值是什么以及如何获得它们将在下一章中看到。每个前面的函数也重新定位精灵。

最后一个函数是stopJump函数,它也将在碰撞检测中使用。它设置了m_IsJumpingm_IsFalling的必要值来结束跳跃。

构建 Thomas 和 Bob 类

现在我们真正要使用继承了。我们将为 Thomas 建立一个类,为 Bob 建立一个类。它们都将继承我们刚刚编写的PlayableCharacter类。然后它们将拥有PlayableCharacter类的所有功能,包括直接访问其protected变量。我们还将添加纯虚函数handleInput的定义。您会注意到,ThomasBobhandleInput函数将是不同的。

编写 Thomas.h

解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件.h)并在名称字段中键入Thomas.h。最后,单击添加按钮。现在我们准备好为Thomas类编写头文件了。

现在将此代码添加到Thomas.h类中:

#pragma once 
#include "PlayableCharacter.h" 

class Thomas : public PlayableCharacter 
{ 
public: 
   // A constructor specific to Thomas 
   Thomas::Thomas(); 

   // The overridden input handler for Thomas 
   bool virtual handleInput(); 

}; 

上面的代码非常简短而简洁。我们可以看到我们有一个构造函数,我们将要实现纯虚的handleInput函数,所以现在让我们来做吧。

编写 Thomas.cpp

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件.cpp)并在名称字段中键入Thomas.cpp。最后,单击添加按钮。现在我们准备好为Thomas类编写.cpp文件了。

Thomas构造函数添加到Thomas.cpp文件中,如下面的片段所示:

#include "stdafx.h" 
#include "Thomas.h" 
#include "TextureHolder.h" 

Thomas::Thomas() 
{ 
   // Associate a texture with the sprite 
   m_Sprite = Sprite(TextureHolder::GetTexture( 
      "graphics/thomas.png")); 

   m_JumpDuration = .45; 
} 

我们只需要加载thomas.png图形并将跳跃持续时间(m_JumpDuration)设置为.45(几乎半秒)。

添加handleInput函数的定义,如下面的片段所示:


// A virtual function 
bool Thomas::handleInput() 
{ 
   m_JustJumped = false; 

   if (Keyboard::isKeyPressed(Keyboard::W)) 
   { 

      // Start a jump if not already jumping 
      // but only if standing on a block (not falling) 
      if (!m_IsJumping && !m_IsFalling) 
      { 
         m_IsJumping = true; 
         m_TimeThisJump = 0; 
         m_JustJumped = true; 
      } 
   } 
   else 
   { 
      m_IsJumping = false; 
      m_IsFalling = true; 

   } 
   if (Keyboard::isKeyPressed(Keyboard::A)) 
   { 
      m_LeftPressed = true; 
   } 
   else 
   { 
      m_LeftPressed = false; 
   } 

   if (Keyboard::isKeyPressed(Keyboard::D)) 
   { 
      m_RightPressed = true; 
   } 
   else 
   { 
      m_RightPressed = false; 
   } 

   return m_JustJumped; 
} 

这段代码应该看起来很熟悉。我们使用 SFML 的isKeyPressed函数来查看WAD键是否被按下。

当按下W键时,玩家正在尝试跳跃。然后代码使用if(!m_IsJumping && !m_IsFalling)代码,检查角色是否已经在跳跃,而且也没有在下落。当这些测试都为真时,m_IsJumping被设置为truem_TimeThisJump被设置为零,m_JustJumped被设置为 true。

当前两个测试不为true时,执行else子句,并将m_Jumping设置为false,将m_IsFalling设置为 true。

按下AD键的处理就是简单地将m_LeftPressed和/或m_RightPressed设置为truefalseupdate函数现在将能够处理移动角色。

函数中的最后一行代码返回m_JustJumped的值。这将让调用代码知道是否需要播放跳跃音效。

我们现在将编写Bob类,尽管这几乎与Thomas类相同,但它具有不同的跳跃能力,不同的Texture,并且在键盘上使用不同的键。

编写 Bob.h

Bob类的结构与Thomas类相同。它继承自PlayableCharacter,有一个构造函数,并提供handleInput函数的定义。与Thomas相比的区别是,我们以不同的方式初始化了一些 Bob 的成员变量,并且我们也以不同的方式处理输入(在handleInput函数中)。让我们编写这个类并看看细节。

解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件.h)并在名称字段中键入Bob.h。最后,单击添加按钮。现在我们准备好为Bob类编写头文件了。

将以下代码添加到Bob.h文件中:

#pragma once 
#include "PlayableCharacter.h" 

class Bob : public PlayableCharacter 
{ 
public: 
   // A constructor specific to Bob 
   Bob::Bob(); 

   // The overriden input handler for Bob 
   bool virtual handleInput(); 

}; 

上面的代码与Thomas.h文件相同,除了类名和构造函数名。

编写 Bob.cpp

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp突出显示,然后在名称字段中键入Thomas.cpp。最后,单击添加按钮。我们现在准备为Bob类编写.cpp文件。

Bob构造函数的代码添加到Bob.cpp文件中。注意纹理不同(bob.png),并且m_JumpDuration初始化为一个显着较小的值。Bob 现在是他自己独特的自己:

#include "stdafx.h" 
#include "Bob.h" 
#include "TextureHolder.h" 

Bob::Bob() 
{ 
   // Associate a texture with the sprite 
   m_Sprite = Sprite(TextureHolder::GetTexture( 
      "graphics/bob.png")); 

   m_JumpDuration = .25; 
} 

Bob构造函数之后立即添加handleInput代码:

bool Bob::handleInput() 
{ 
   m_JustJumped = false; 

   if (Keyboard::isKeyPressed(Keyboard::Up)) 
   { 

      // Start a jump if not already jumping 
      // but only if standing on a block (not falling) 
      if (!m_IsJumping && !m_IsFalling) 
      { 
         m_IsJumping = true; 
         m_TimeThisJump = 0; 
         m_JustJumped = true; 
      } 

   } 
   else 
   { 
      m_IsJumping = false; 
      m_IsFalling = true; 

   } 
   if (Keyboard::isKeyPressed(Keyboard::Left)) 
   { 
      m_LeftPressed = true; 

   } 
   else 
   { 
      m_LeftPressed = false; 
   } 

   if (Keyboard::isKeyPressed(Keyboard::Right)) 
   { 

      m_RightPressed = true;; 

   } 
   else 
   { 
      m_RightPressed = false; 
   } 

   return m_JustJumped; 
} 

注意,代码几乎与Thomas类的handleInput函数中的代码相同。唯一的区别是我们对不同的键(箭头键,箭头键和箭头键用于跳跃)做出响应。

现在我们有一个PlayableCharacter类,它已经被BobThomas扩展,我们可以在游戏中添加一个Bob和一个Thomas实例。

更新游戏引擎以使用 Thomas 和 Bob

为了能够运行游戏并看到我们的新角色,我们必须声明它们的实例,调用它们的spawn函数,每帧更新它们,并每帧绘制它们。现在让我们来做这个。

更新 Engine.h 以添加 Bob 和 Thomas 的实例

打开Engine.h文件并添加下面突出显示的代码行,如下所示:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "TextureHolder.h" 
#include "Thomas.h"
#include "Bob.h" 

using namespace sf; 

class Engine 
{ 
private: 
   // The texture holder 
   TextureHolder th; 

 // Thomas and his friend, Bob
   Thomas m_Thomas;
   Bob m_Bob; 

   const int TILE_SIZE = 50; 
   const int VERTS_IN_QUAD = 4; 
   ... 
   ... 

现在我们有了ThomasBob的实例,它们都是从PlayableCharacter派生出来的。

更新输入函数以控制 Thomas 和 Bob

现在我们将添加控制这两个角色的能力。这段代码将放在代码的输入部分。当然,对于这个项目,我们有一个专门的input函数。打开Input.cpp并添加这段突出显示的代码:

void Engine::input() 
{ 
   Event event; 
   while (m_Window.pollEvent(event)) 
   { 
      if (event.type == Event::KeyPressed) 
      { 
         // Handle the player quitting 
         if (Keyboard::isKeyPressed(Keyboard::Escape)) 
         { 
            m_Window.close(); 
         } 

         // Handle the player starting the game 
         if (Keyboard::isKeyPressed(Keyboard::Return)) 
         { 
            m_Playing = true; 
         } 

         // Switch between Thomas and Bob 
         if (Keyboard::isKeyPressed(Keyboard::Q)) 
         { 
            m_Character1 = !m_Character1; 
         } 

         // Switch between full and split-screen 
         if (Keyboard::isKeyPressed(Keyboard::E)) 
         { 
            m_SplitScreen = !m_SplitScreen; 
         } 
      } 
   } 

 // Handle input specific to Thomas
   if(m_Thomas.handleInput())
   {
     // Play a jump sound
   }

   // Handle input specific to Bob
   if(m_Bob.handleInput())
   {
     // Play a jump sound
   } 
} 

请注意,以前的代码是多么简单,因为所有功能都包含在ThomasBob类中。所有代码只需为ThomasBob类添加一个包含指令。然后,在input函数中,代码只需在m_Thomasm_Bob上调用纯虚拟的handleInput函数。我们将每个调用包装在if语句中的原因是因为它们基于刚刚成功启动的新跳跃返回truefalse。我们将在第十五章中处理播放跳跃音效,声音空间化和 HUD

更新更新函数以生成和更新 PlayableCharacter 实例

这被分成两部分。首先,我们需要在新级别开始时生成 Bob 和 Thomas,其次,我们需要每帧更新(通过调用它们的update函数)。

生成 Thomas 和 Bob

随着项目的进展,我们需要在几个不同的地方调用我们的ThomasBob对象的生成函数。最明显的是,当新级别开始时,我们需要生成这两个角色。在接下来的章节中,随着我们需要在级别开始时执行的任务数量增加,我们将编写一个loadLevel函数。现在,让我们在update函数中调用m_Thomasm_Bobspawn函数,如下所示的突出显示的代码。添加这段代码,但请记住,这段代码最终将被删除并替换:

void Engine::update(float dtAsSeconds) 
{ 
 if (m_NewLevelRequired)
   {
     // These calls to spawn will be moved to a new
     // loadLevel() function soon
     // Spawn Thomas and Bob
     m_Thomas.spawn(Vector2f(0,0), GRAVITY);
     m_Bob.spawn(Vector2f(100, 0), GRAVITY); 

     // Make sure spawn is called only once
     m_TimeRemaining = 10;
     m_NewLevelRequired = false;
   } 

   if (m_Playing) 
   { 
      // Count down the time the player has left 
      m_TimeRemaining -= dtAsSeconds; 

      // Have Thomas and Bob run out of time? 
      if (m_TimeRemaining <= 0) 
      { 
         m_NewLevelRequired = true; 
      } 

   }// End if playing 

} 

先前的代码只是调用spawn并传入游戏世界中的位置以及重力。该代码包裹在一个if语句中,检查是否需要新的级别。实际的生成代码将被移动到一个专门的loadLevel函数中,但if条件将成为完成项目的一部分。此外,m_TimeRemaining被设置为一个相当任意的 10 秒。

每帧更新 Thomas 和 Bob

接下来,我们将更新 Thomas 和 Bob。我们只需要调用它们的update函数并传入本帧所花费的时间。

添加下面突出显示的代码:

void Engine::update(float dtAsSeconds) 
{ 
   if (m_NewLevelRequired) 
   { 
      // These calls to spawn will be moved to a new 
      // LoadLevel function soon 
      // Spawn Thomas and Bob 
      m_Thomas.spawn(Vector2f(0,0), GRAVITY); 
      m_Bob.spawn(Vector2f(100, 0), GRAVITY); 

      // Make sure spawn is called only once 
      m_NewLevelRequired = false; 
   } 

   if (m_Playing) 
   { 
 // Update Thomas
      m_Thomas.update(dtAsSeconds);

      // Update Bob
      m_Bob.update(dtAsSeconds); 

      // Count down the time the player has left 
      m_TimeRemaining -= dtAsSeconds; 

      // Have Thomas and Bob run out of time? 
      if (m_TimeRemaining <= 0) 
      { 
         m_NewLevelRequired = true; 
      } 

   }// End if playing 

} 

现在角色可以移动了,我们需要更新适当的View对象,使其围绕角色居中并使其成为关注的中心。当然,直到我们的游戏世界中有一些物体,才能实现实际移动的感觉。

添加下面片段中显示的突出代码:

void Engine::update(float dtAsSeconds) 
{ 
   if (m_NewLevelRequired) 
   { 
      // These calls to spawn will be moved to a new 
      // LoadLevel function soon 
      // Spawn Thomas and Bob 
      m_Thomas.spawn(Vector2f(0,0), GRAVITY); 
      m_Bob.spawn(Vector2f(100, 0), GRAVITY); 

      // Make sure spawn is called only once 
      m_NewLevelRequired = false; 
   } 

   if (m_Playing) 
   { 
      // Update Thomas 
      m_Thomas.update(dtAsSeconds); 

      // Update Bob 
      m_Bob.update(dtAsSeconds); 

      // Count down the time the player has left 
      m_TimeRemaining -= dtAsSeconds; 

      // Have Thomas and Bob run out of time? 
      if (m_TimeRemaining <= 0) 
      { 
         m_NewLevelRequired = true; 
      } 

   }// End if playing 

 // Set the appropriate view around the appropriate character
   if (m_SplitScreen)
   {
     m_LeftView.setCenter(m_Thomas.getCenter());
     m_RightView.setCenter(m_Bob.getCenter());
   }
   else
   {
     // Centre full screen around appropriate character
     if (m_Character1)
     {
        m_MainView.setCenter(m_Thomas.getCenter());
     }
     else
     {
        m_MainView.setCenter(m_Bob.getCenter());
     }
   } 
} 

先前的代码处理了两种可能的情况。首先,if(mSplitScreen)条件将左侧视图定位在m_Thomas周围,右侧视图定位在m_Bob周围。当游戏处于全屏模式时执行的else子句测试m_Character1是否为true。如果是,则全屏视图(m_MainView)围绕 Thomas 居中,否则围绕 Bob 居中。您可能还记得玩家可以使用E键在分屏模式和全屏模式之间切换,使用Q键在全屏模式下切换 Bob 和 Thomas。我们在Engine类的input函数中编写了这些内容,回到第十二章。

绘制 Bob 和 Thomas

确保Draw.cpp文件已打开,并添加下面片段中显示的突出代码:

void Engine::draw() 
{ 
   // Rub out the last frame 
   m_Window.clear(Color::White); 

   if (!m_SplitScreen) 
   { 
      // Switch to background view 
      m_Window.setView(m_BGMainView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_MainView 
      m_Window.setView(m_MainView);     

 // Draw thomas
     m_Window.draw(m_Thomas.getSprite());

     // Draw bob
     m_Window.draw(m_Bob.getSprite()); 
   } 
   else 
   { 
      // Split-screen view is active 

      // First draw Thomas' side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGLeftView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_LeftView 
      m_Window.setView(m_LeftView); 

 // Draw bob
     m_Window.draw(m_Bob.getSprite());

     // Draw thomas
     m_Window.draw(m_Thomas.getSprite()); 

      // Now draw Bob's side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGRightView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_RightView 
      m_Window.setView(m_RightView); 

 // Draw thomas
     m_Window.draw(m_Thomas.getSprite());

     // Draw bob
     m_Window.draw(m_Bob.getSprite()); 

   } 

   // Draw the HUD 
   // Switch to m_HudView 
   m_Window.setView(m_HudView); 

   // Show everything we have just drawn 
   m_Window.display(); 
} 

请注意,我们在全屏、左侧和右侧都绘制了 Thomas 和 Bob。还要注意在分屏模式下绘制角色的微妙差异。在绘制屏幕的左侧时,我们改变了角色的绘制顺序,并在 Bob 之后绘制 Thomas。因此,Thomas 将始终位于左侧的顶部,Bob 位于右侧。这是因为左侧是为控制 Thomas 的玩家而设计的,右侧是为控制 Bob 的玩家而设计的。

您可以运行游戏,看到 Thomas 和 Bob 位于屏幕中央:

绘制 Bob 和 Thomas

如果按下Q键从 Thomas 切换到 Bob,您将看到View进行了轻微调整。如果移动任何一个角色向左或向右(Thomas 使用AD,Bob 使用箭头键),您将看到它们相对移动。

尝试按下E键在全屏和分屏模式之间切换。然后尝试再次移动两个角色以查看效果。在下面的截图中,您可以看到 Thomas 始终位于左侧窗口的中心,Bob 始终位于右侧窗口的中心:

绘制 Bob 和 Thomas

如果您让游戏运行足够长的时间,角色将每十秒重新生成在它们的原始位置。这是我们在完成游戏时需要的功能的开端。这种行为是由m_TimeRemaining变为负值,然后将m_NewLevelRequired变量设置为true引起的。

还要注意,直到我们绘制了关卡的细节,我们才能看到移动的完整效果。实际上,虽然看不到,但两个角色都在以每秒 300 像素的速度持续下落。由于摄像机每帧都围绕它们居中,并且游戏世界中没有其他物体,我们看不到这种向下运动。

如果您想自己演示这一点,只需按照以下代码中所示更改对m_Bob.spawn的调用:

m_Bob.spawn(Vector2f(0,0), 0); 

现在 Bob 没有重力效果,Thomas 会明显远离他。如下截图所示:

绘制 Bob 和 Thomas

在接下来的章节中,我们将添加一些可玩的关卡进行交互。

常见问题解答

Q)我们学习了多态性,但到目前为止,我没有注意到游戏代码中有任何多态性。

A)在接下来的章节中,当我们编写一个以PlayableCharacter作为参数的函数时,我们将看到多态性的实际应用。我们将看到如何可以将 Bob 或 Thomas 传递给这个新函数,并且无论使用哪个,它都能正常工作。

摘要

在本章中,我们学习了一些新的 C++概念。首先,继承允许我们扩展一个类并获得其所有功能。我们还学到,我们可以将变量声明为受保护的,这将使子类可以访问它们,但它们仍将被封装(隐藏)在所有其他代码之外。我们还使用了纯虚函数,这使得一个类成为抽象类,意味着该类不能被实例化,因此必须从中继承/扩展。我们还介绍了多态的概念,但需要等到下一章才能在我们的游戏中使用它。

接下来,我们将为游戏添加一些重要功能。在接下来的一章中,Thomas 和 Bob 将会行走、跳跃和下落。他们甚至可以跳在彼此的头上,以及探索从文本文件加载的一些关卡设计。

第十四章:构建可玩关卡和碰撞检测

这一章可能是这个项目中最令人满意的一章。原因是到最后,我们将有一个可玩的游戏。虽然还有一些功能要实现(声音、粒子效果、HUD 和着色器效果),但 Bob 和 Thomas 将能够奔跑、跳跃和探索世界。此外,您将能够通过简单地在文本文件中制作平台和障碍物来创建几乎任何大小或复杂度的关卡设计。

通过本章的以下主题,我们将实现所有这些:

  • 探索如何在文本文件中设计关卡

  • 构建一个LevelManager类,它将从文本文件中加载关卡,将其转换为游戏可以使用的数据,并跟踪关卡细节,如生成位置、当前关卡和允许的时间限制

  • 更新游戏引擎以使用LevelManager

  • 编写一个多态函数来处理 Bob 和 Thomas 的碰撞检测

设计一些关卡

记得我们在第十二章中介绍的精灵表吗,抽象和代码管理-更好地利用 OOP。这里再次显示,用数字注释表示我们将从中构建关卡的每个瓷砖:

设计一些关卡

我将截图放在灰色背景上,这样您可以清楚地看到精灵表的不同细节。方格背景表示透明度级别。因此,除了数字 1 之外的所有瓷砖都会至少部分显示其背后的背景:

  • 瓷砖 0 完全透明,将用于填补没有其他瓷砖的空白处

  • 瓷砖 1 是 Thomas 和 Bob 将走在上面的平台

  • 瓷砖 2 用于火砖,瓷砖 3 用于水砖

  • 瓷砖 4,您可能需要仔细查看。它有一个白色的方形轮廓。这是 Thomas 和 Bob 必须一起到达的关卡目标。

在讨论设计关卡时,请记住这个截图。

我们将在文本文件中输入这些瓷砖编号的组合来设计布局。举个例子:

0000000000000000000000000000000000000000000000 
0000000000000000000000000000000000000000000000 
0000000000000000000000000000000000000000000000 
0000000000000000000000000000000000000000000000 
0000000000000000000000000000000000000000000000 
0000000000000000000000000000000000000000000000 
1111111111000111111222222221111133111111111411 
0000000000000000001222222221000133100000001110 
0000000000000000001222222221000133100000000000 
0000000000000000001222222221000133100000000000 
0000000000000000001111111111000111100000000000 

先前的代码转换为以下关卡布局:

设计一些关卡

请注意,为了获得前一个截图中显示的视图,我必须缩小View。此外,截图被裁剪了。关卡的实际起始位置将看起来像下面的截图:

设计一些关卡

向您展示这些截图的目的有两个。首先,您可以看到如何使用简单和免费的文本编辑器快速构建关卡设计。

提示

只需确保使用等宽字体,以便所有数字大小相同。这样设计关卡就会更容易。

其次,这些截图展示了设计的游戏方面。在关卡中,Thomas 和 Bob 首先需要跳过一个小洞,否则他们将掉入死亡(重新生成)。然后他们需要穿过一大片火焰。实际上,Bob 不可能跳过那么多块砖。玩家们需要共同解决问题。Bob 清除火砖的唯一方法是站在 Thomas 的头上,然后从那里跳,如下截图所示:

设计一些关卡

然后很容易到达目标并进入下一关。

提示

我强烈建议您完成本章,然后花一些时间设计自己的关卡。

我已经包含了一些关卡设计供您参考。它们位于我们在第十二章中添加到项目中的levels文件夹中,抽象和代码管理-更好地利用 OOP

接下来是游戏的一些缩小视图,以及关卡设计代码的屏幕截图。代码的屏幕截图可能比重现实际的文本内容更有用。如果您确实想看到代码,只需在levels文件夹中打开文件。

这就是代码的样子:

设计一些关卡

这是前面的代码将产生的关卡布局:

设计一些关卡

这个关卡是我在第十二章中提到的“信任之跃”关卡,抽象和代码管理-更好地利用 OOP

设计一些关卡

我已经突出显示了平台,因为在缩小的屏幕截图中它们不太清晰:

设计一些关卡

提供的设计很简单。游戏引擎将能够处理非常大的设计。您可以自由发挥想象力,构建一些非常大且难以完成的关卡。

当然,除非我们学会如何加载它们并将文本转换为可玩的关卡,否则这些设计实际上不会做任何事情。此外,在实现碰撞检测之前,将无法站在任何平台上。

首先,让我们处理加载关卡设计。

构建 LevelManager 类

我们需要经过几个阶段的编码才能使我们的关卡设计起作用。我们将首先编写LevelManager头文件。这将使我们能够查看和讨论LevelManger类中的成员变量和函数。

接下来,我们将编写LevelManager.cpp文件,其中将包含所有的函数定义。由于这是一个很长的文件,我们将把它分成几个部分,以便编码和讨论。

一旦LevelManager类完成,我们将在游戏引擎(Engine类)中添加一个实例。我们还将在Engine类中添加一个新函数loadLevel,我们可以在需要新关卡时从update函数中调用。loadLevel函数不仅将使用LevelManager实例来加载适当的关卡,还将处理诸如生成玩家角色和准备时钟等方面。

如前所述,让我们通过编写LevelManager.h文件来概述LevelManager

编码 LevelManager.h

解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件( .h 并在名称字段中键入LevelManager.h。最后,单击添加按钮。我们现在准备编写LevelManager类的头文件。

添加以下包含指令和私有变量,然后我们将讨论它们:

#pragma once 

#include <SFML/Graphics.hpp> 
using namespace sf; 
using namespace std; 

class LevelManager 
{ 
private: 
   Vector2i m_LevelSize; 
   Vector2f m_StartPosition; 
   float m_TimeModifier = 1; 
   float m_BaseTimeLimit = 0; 
   int m_CurrentLevel = 0; 
   const int NUM_LEVELS = 4; 

// public declarations go here 

代码声明了Vector2i``m_LevelSize,用于保存当前地图包含的水平和垂直瓦片数的两个整数值。Vector2f``m_StartPosition包含 Bob 和 Thomas 应该生成的世界坐标。请注意,这不是与m_LevelSize单位相关的瓦片位置,而是关卡中的水平和垂直像素位置。

m_TimeModifier成员变量是一个浮点数,将用于乘以当前关卡中可用的时间。我们之所以要这样做,是因为通过改变(减少)这个值,我们将在玩家尝试同一关卡时缩短可用的时间。例如,如果玩家第一次尝试第一关有 60 秒的时间,那么 60 乘以 1 当然是 60。当玩家完成所有关卡并再次回到第一关时,m_TimeModifier将减少 10%。然后,当可用时间乘以 0.9 时,玩家可用的时间将是 54 秒。这比 60 少 10%。游戏将逐渐变得更加困难。

浮点变量m_BaseTimeLimit保存了我们刚刚讨论的原始未修改的时间限制。

您可能已经猜到m_CurrentLevel将保存当前正在播放的关卡编号。

int NUM_LEVELS常量将用于标记何时适合再次返回到第一关,并减少m_TimeModifier的值。

现在添加以下公共变量和函数声明:

public: 

   const int TILE_SIZE = 50; 
   const int VERTS_IN_QUAD = 4; 

   float getTimeLimit(); 

   Vector2f getStartPosition(); 

   int** nextLevel(VertexArray& rVaLevel); 

   Vector2i getLevelSize(); 

   int getCurrentLevel(); 

}; 

在上一段代码中,有两个常量int成员。TILE_SIZE是一个有用的常量,提醒我们精灵表中的每个图块都是五十像素宽和五十像素高。VERTS_IN_QUAD是一个有用的常量,使我们对VertexArray的操作不那么容易出错。实际上,一个四边形中有四个顶点。现在我们不能忘记它。

getTimeLimitgetStartPositiongetLevelSizegetCurrentLevel函数是简单的 getter 函数,返回我们在上一段代码中声明的私有成员变量的当前值。

值得仔细研究的一个函数是nextLevel。这个函数接收一个VertexArray引用,就像我们在 Zombie Arena 游戏中使用的那样。函数可以在VertexArray上工作,所有的更改都将出现在调用代码中的VertexArray中。nextLevel函数返回一个指向指针的指针,这意味着我们可以返回一个地址,该地址是int值的二维数组的第一个元素。我们将构建一个int值的二维数组,该数组将表示每个关卡的布局。当然,这些int值将从关卡设计文本文件中读取。

编写LevelManager.cpp文件。

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp,然后在名称字段中键入LevelManager.cpp。最后,单击添加按钮。现在我们准备编写LevelManager类的.cpp文件。

由于这是一个相当长的类,我们将把它分成六个部分进行讨论。前五部分将涵盖nextLevel函数,第六部分将涵盖其他所有内容。

添加以下包含指令和nextLevel函数的第一部分(共五部分):

#include "stdafx.h" 
#include <SFML/Graphics.hpp> 
#include <SFML/Audio.hpp> 
#include "TextureHolder.h" 
#include <sstream> 
#include <fstream> 
#include "LevelManager.h" 

using namespace sf; 
using namespace std; 

int** LevelManager::nextLevel(VertexArray& rVaLevel) 
{ 
   m_LevelSize.x = 0; 
   m_LevelSize.y = 0; 

   // Get the next level 
   m_CurrentLevel++; 
   if (m_CurrentLevel > NUM_LEVELS) 
   { 
      m_CurrentLevel = 1; 
      m_TimeModifier -= .1f; 
   } 

   // Load the appropriate level from a text file 
   string levelToLoad; 
   switch (m_CurrentLevel) 
   { 

   case 1: 
      levelToLoad = "levels/level1.txt"; 
      m_StartPosition.x = 100; 
      m_StartPosition.y = 100; 
      m_BaseTimeLimit = 30.0f; 
      break; 

   case 2: 
      levelToLoad = "levels/level2.txt"; 
      m_StartPosition.x = 100; 
      m_StartPosition.y = 3600; 
      m_BaseTimeLimit = 100.0f; 
      break; 

   case 3: 
      levelToLoad = "levels/level3.txt"; 
      m_StartPosition.x = 1250; 
      m_StartPosition.y = 0; 
      m_BaseTimeLimit = 30.0f; 
      break; 

   case 4: 
      levelToLoad = "levels/level4.txt"; 
      m_StartPosition.x = 50; 
      m_StartPosition.y = 200; 
      m_BaseTimeLimit = 50.0f; 
      break; 

   }// End switch 

在包含指令之后,代码将m_LevelSize.xm_LevelSize.y初始化为零。

接下来,增加m_CurrentLevel。随后的if语句检查m_CurrentLevel是否大于NUM_LEVELS。如果是,则将m_CurrentLevel设置回1,并且m_TimeModifier减少.1f,以缩短所有关卡的允许时间。

然后,根据m_CurrentLevel的值进行代码切换。每个case语句都初始化文本文件的名称,该文件包含了关卡设计和 Thomas 和 Bob 的起始位置,以及m_BaseTimeLimit,这是该关卡的未修改时间限制。

提示

如果您设计自己的关卡,请在此处添加case语句和相应的值。还要编辑LevelManager.h文件中的NUM_LEVELS常量。

现在添加nextLevel函数的第二部分,如下所示。在上一段代码之后立即添加代码。在添加代码时,请仔细研究,以便我们可以讨论它。

   ifstream inputFile(levelToLoad); 
   string s; 

   // Count the number of rows in the file 
   while (getline(inputFile, s)) 
   { 
      ++m_LevelSize.y; 
   } 

   // Store the length of the rows 
   m_LevelSize.x = s.length(); 

在我们刚刚编写的上一部分(第二部分)中,我们声明了一个名为inputFileifstream对象,它打开了一个流到levelToLoad中包含的文件名。

代码使用getline循环遍历文件的每一行,但不记录任何内容。它只是通过递增m_LevelSize.y来计算行数。在for循环之后,使用s.length将关卡的宽度保存在m_LevelSize.x中。这意味着所有行的长度必须相同,否则我们会遇到问题。

此时,我们知道并已保存了m_LevelSize中当前关卡的长度和宽度。

现在按照所示添加nextLevel函数的第三部分。在上一段代码之后立即添加代码。在添加代码时,请仔细研究代码,以便我们可以讨论它:

   // Go back to the start of the file 
   inputFile.clear(); 
   inputFile.seekg(0, ios::beg); 

   // Prepare the 2d array to hold the int values from the file 
   int** arrayLevel = new int*[m_LevelSize.y]; 
   for (int i = 0; i < m_LevelSize.y; ++i) 
   { 
      // Add a new array into each array element 
      arrayLevel[i] = new int[m_LevelSize.x]; 
   } 

首先,使用其clear函数清除inputFile。使用带有0, ios::beg参数的seekg函数将流重置到第一个字符之前。

接下来,我们声明一个指向指针的arrayLevel。请注意,这是使用new关键字在自由存储/堆上完成的。一旦我们初始化了这个二维数组,我们就能够将其地址返回给调用代码,并且它将持续存在,直到我们删除它或游戏关闭。

for循环从 0 到m_LevelSize.y -1。在每次循环中,它向堆中添加一个新的int值数组,以匹配m_LevelSize.x的值。现在,我们有一个完全配置好的(对于当前级别)二维数组。唯一的问题是里面什么都没有。

现在按照所示添加nextLevel函数的第四部分。在上一段代码之后立即添加代码。在添加代码时,请仔细研究代码,以便我们可以讨论它:

    // Loop through the file and store all the values in the 2d array 
   string row; 
   int y = 0; 
   while (inputFile >> row) 
   { 
      for (int x = 0; x < row.length(); x++) { 

         const char val = row[x]; 
         arrayLevel[y][x] = atoi(&val); 
      } 

      y++; 
   } 

   // close the file 
   inputFile.close(); 

首先,代码初始化一个名为rowstring,它将一次保存一个级别设计的行。我们还声明并初始化一个名为yint,它将帮助我们计算行数。

while循环重复执行,直到inputFile超过最后一行。在while循环内部有一个for循环,它遍历当前行的每个字符,并将其存储在二维数组arrayLevel中。请注意,我们使用arrayLevel[y][x] =准确地访问二维数组的正确元素。atoi函数将char val转换为int。这是必需的,因为我们有一个用于int而不是char的二维数组。

现在按照所示添加nextLevel函数的第五部分。在上一段代码之后立即添加代码。在添加代码时,请仔细研究代码,以便我们可以讨论它:

   // What type of primitive are we using? 
   rVaLevel.setPrimitiveType(Quads); 

   // Set the size of the vertex array 
   rVaLevel.resize(m_LevelSize.x * m_LevelSize.y * VERTS_IN_QUAD); 

   // Start at the beginning of the vertex array 
   int currentVertex = 0; 

   for (int x = 0; x < m_LevelSize.x; x++) 
   { 
      for (int y = 0; y < m_LevelSize.y; y++) 
      { 
         // Position each vertex in the current quad 
         rVaLevel[currentVertex + 0].position =  
            Vector2f(x * TILE_SIZE,  
            y * TILE_SIZE); 

         rVaLevel[currentVertex + 1].position =  
            Vector2f((x * TILE_SIZE) + TILE_SIZE,  
            y * TILE_SIZE); 

         rVaLevel[currentVertex + 2].position =  
            Vector2f((x * TILE_SIZE) + TILE_SIZE,  
            (y * TILE_SIZE) + TILE_SIZE); 

         rVaLevel[currentVertex + 3].position =  
            Vector2f((x * TILE_SIZE),  
            (y * TILE_SIZE) + TILE_SIZE); 

         // Which tile from the sprite sheet should we use 
         int verticalOffset = arrayLevel[y][x] * TILE_SIZE; 

         rVaLevel[currentVertex + 0].texCoords =  
            Vector2f(0, 0 + verticalOffset); 

         rVaLevel[currentVertex + 1].texCoords =  
            Vector2f(TILE_SIZE, 0 + verticalOffset); 

         rVaLevel[currentVertex + 2].texCoords =  
            Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset); 

         rVaLevel[currentVertex + 3].texCoords =  
            Vector2f(0, TILE_SIZE + verticalOffset); 

         // Position ready for the next four vertices 
         currentVertex = currentVertex + VERTS_IN_QUAD; 
      } 
   } 

   return arrayLevel; 
} // End of nextLevel function 

尽管这是我们将nextLevel分成五个部分中最长的代码部分,但它也是最直接的。这是因为我们在 Zombie Arena 项目中看到了非常相似的代码。

嵌套的for循环循环从零到级别的宽度和高度。对于数组中的每个位置,将四个顶点放入VertexArray,并从精灵表中分配四个纹理坐标。顶点和纹理坐标的位置是使用currentVertex变量、TILE SIZEVERTS_IN_QUAD常量计算的。在内部for循环的每次循环结束时,currentVertex增加VERTS_IN_QUAD,很好地移动到下一个瓷砖。

关于这个VertexArray的重要一点是它是通过引用传递给nextLevel的。因此,VertexArray将在调用代码中可用。我们将从Engine类中的代码中调用nextLevel

一旦调用了这个函数,Engine类将有一个VertexArray来图形化表示级别,并且有一个int值的二维数组作为级别中所有平台和障碍物的数值表示。

LevelManager的其余函数都是简单的 getter 函数,但请花时间熟悉哪个私有值由哪个函数返回。添加LevelManager类的其余函数:

Vector2i LevelManager::getLevelSize() 
{ 
   return m_LevelSize; 
} 

int LevelManager::getCurrentLevel() 
{ 
   return m_CurrentLevel; 
} 

float LevelManager::getTimeLimit() 
{ 
   return m_BaseTimeLimit * m_TimeModifier; 

} 
Vector2f LevelManager::getStartPosition() 
{ 
   return m_StartPosition; 
} 

现在LevelManager类已经完成,我们可以继续使用它。我们将在 Engine 类中编写另一个函数来实现这一点。

编写 loadLevel 函数

要明确的是,这个函数是Engine类的一部分,尽管它将大部分工作委托给其他函数,包括我们刚刚构建的LevelManager类的函数。

首先,让我们将新函数的声明以及Engine.h文件中的一些其他新代码添加到Engine.h文件中。打开Engine.h文件,并添加以下Engine.h文件的摘要快照中显示的突出显示的代码行:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "TextureHolder.h" 
#include "Thomas.h" 
#include "Bob.h" 
#include "LevelManager.h" 

using namespace sf; 

class Engine 
{ 
private: 
   // The texture holder 
   TextureHolder th; 

   // Thomas and his friend, Bob 
   Thomas m_Thomas; 
   Bob m_Bob; 

 // A class to manage all the levels
   LevelManager m_LM; 

   const int TILE_SIZE = 50; 
   const int VERTS_IN_QUAD = 4; 

   // The force pushing the characters down 
   const int GRAVITY = 300; 

   // A regular RenderWindow 
   RenderWindow m_Window; 

   // The main Views 
   View m_MainView; 
   View m_LeftView; 
   View m_RightView; 

   // Three views for the background 
   View m_BGMainView; 
   View m_BGLeftView; 
   View m_BGRightView; 

   View m_HudView; 

   // Declare a sprite and a Texture for the background 
   Sprite m_BackgroundSprite; 
   Texture m_BackgroundTexture; 

   // Is the game currently playing? 
   bool m_Playing = false; 

   // Is character 1 or 2 the current focus? 
   bool m_Character1 = true; 

   // Start in full screen mode 
   bool m_SplitScreen = false; 

   // How much time is left in the current level 
   float m_TimeRemaining = 10; 
   Time m_GameTimeTotal; 

   // Is it time for a new/first level? 
   bool m_NewLevelRequired = true; 

 // The vertex array for the level tiles
   VertexArray m_VALevel;
   // The 2d array with the map for the level
   // A pointer to a pointer
   int** m_ArrayLevel =  NULL;
   // Texture for the level tiles
   Texture m_TextureTiles; 
   // Private functions for internal use only 
   void input(); 
   void update(float dtAsSeconds); 
   void draw();    

 // Load a new level
   void loadLevel(); 

public: 
   // The Engine constructor 
   Engine(); 

   ... 
   ...       
   ... 

您可以在前面的代码中看到以下内容:

  • 我们包含了LevelManager.h文件

  • 我们添加了一个名为m_LMLevelManager实例

  • 我们添加了一个名为m_VALevelVertexArray

  • 我们添加了一个指向int的指针,它将保存从nextLevel返回的二维数组

  • 我们为精灵表添加了一个新的Texture对象

  • 我们添加了loadLevel函数的声明,我们现在将编写这个函数

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,突出显示(单击左键)C++文件( .cpp ,然后在名称字段中键入LoadLevel.cpp。最后,单击添加按钮。我们现在准备编写loadLevel函数。

loadLevel函数的代码添加到LoadLevel.cpp文件中,然后我们可以讨论它:

#include "stdafx.h" 
#include "Engine.h" 

void Engine::loadLevel() 
{ 
   m_Playing = false; 

   // Delete the previously allocated memory 
   for (int i = 0; i < m_LM.getLevelSize().y; ++i) 
   { 
      delete[] m_ArrayLevel[i]; 

   } 
   delete[] m_ArrayLevel; 

   // Load the next 2d array with the map for the level 
   // And repopulate the vertex array as well 
   m_ArrayLevel = m_LM.nextLevel(m_VALevel); 

   // How long is this new time limit 
   m_TimeRemaining = m_LM.getTimeLimit(); 

   // Spawn Thomas and Bob 
   m_Thomas.spawn(m_LM.getStartPosition(), GRAVITY); 
   m_Bob.spawn(m_LM.getStartPosition(), GRAVITY); 

   // Make sure this code isn't run again 
   m_NewLevelRequired = false; 
} 

首先,我们将m_Playing设置为 false,以阻止更新函数的部分执行。接下来,我们循环遍历m_ArrayLevel中的所有水平数组并删除它们。在for循环之后,我们删除m_ArrayLevel

代码m_ArrayLevel = m_LM.nextLevel(m_VALevel)调用nextLevel,准备VertexArraym_VALevel,以及二维m_ArrayLevel数组。关卡已经设置好,准备就绪。

通过调用getTimeLimit来初始化m_TimeRemaining,并使用spawn函数生成 Thomas 和 Bob,以及从getStartPosition返回的值。

最后,m_NewLevelRequired设置为false。正如我们将在几页后看到的,将m_NewLevelRequired设置为true会导致调用loadLevel。我们只想运行这个函数一次。

更新引擎

打开Engine.cpp文件,并在Engine构造函数的末尾添加突出显示的代码以加载精灵表纹理:

Engine::Engine() 
{ 
   // Get the screen resolution and create an SFML window and View 
   Vector2f resolution; 
   resolution.x = VideoMode::getDesktopMode().width; 
   resolution.y = VideoMode::getDesktopMode().height; 

   m_Window.create(VideoMode(resolution.x, resolution.y), 
      "Thomas was late", 
      Style::Fullscreen); 

   // Initialize the full screen view 
   m_MainView.setSize(resolution); 
   m_HudView.reset( 
      FloatRect(0, 0, resolution.x, resolution.y)); 

   // Inititialize the split-screen Views 
   m_LeftView.setViewport( 
      FloatRect(0.001f, 0.001f, 0.498f, 0.998f)); 

   m_RightView.setViewport( 
      FloatRect(0.5f, 0.001f, 0.499f, 0.998f)); 

   m_BGLeftView.setViewport( 
      FloatRect(0.001f, 0.001f, 0.498f, 0.998f)); 

   m_BGRightView.setViewport( 
      FloatRect(0.5f, 0.001f, 0.499f, 0.998f)); 

   // Can this graphics card use shaders? 
   if (!sf::Shader::isAvailable()) 
   { 
      // Time to get a new PC 
      m_Window.close(); 
   } 

   m_BackgroundTexture = TextureHolder::GetTexture( 
      "graphics/background.png"); 

   // Associate the sprite with the texture 
   m_BackgroundSprite.setTexture(m_BackgroundTexture); 

 // Load the texture for the background vertex array
   m_TextureTiles = TextureHolder::GetTexture("graphics/tiles_sheet.png"); 
} 

在前面的代码中,我们只是将精灵表加载到m_TextureTiles中。

打开Update.cpp文件并进行以下突出显示的更改和添加:

void Engine::update(float dtAsSeconds) 
{ 
   if (m_NewLevelRequired) 
   { 
      // These calls to spawn will be moved to a new 
      // LoadLevel function soon 
      // Spawn Thomas and Bob 
 //m_Thomas.spawn(Vector2f(0,0), GRAVITY);
      //m_Bob.spawn(Vector2f(100, 0), GRAVITY); 

      // Make sure spawn is called only once 
 //m_TimeRemaining = 10;
      //m_NewLevelRequired = false;

      // Load a level
      loadLevel();        
   } 

实际上,我们应该删除而不是注释掉我们不再使用的行。我只是以这种方式向你展示,以便更清楚地看到更改。在前面的if语句中,应该只有对loadLevel的调用。

最后,在我们可以看到本章迄今为止的工作结果之前,打开Draw.cpp文件并进行以下突出显示的添加以绘制表示关卡的顶点数组:

void Engine::draw() 
{ 
   // Rub out the last frame 
   m_Window.clear(Color::White); 

   if (!m_SplitScreen) 
   { 
      // Switch to background view 
      m_Window.setView(m_BGMainView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_MainView 
      m_Window.setView(m_MainView);     

 // Draw the Level
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 
   } 
   else 
   { 
      // Split-screen view is active 

      // First draw Thomas' side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGLeftView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_LeftView 
      m_Window.setView(m_LeftView); 

 // Draw the Level
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Now draw Bob's side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGRightView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_RightView 
      m_Window.setView(m_RightView); 

 // Draw the Level
     m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw bob 
      m_Window.draw(m_Bob.getSprite()); 

   } 

   // Draw the HUD 
   // Switch to m_HudView 
   m_Window.setView(m_HudView); 

   // Show everything we have just drawn 
   m_Window.display(); 
} 

请注意,我们需要为所有屏幕选项(全屏,左侧和右侧)绘制VertexArray

现在你可以运行游戏了。不幸的是,Thomas 和 Bob 直接穿过了我们精心设计的所有平台。因此,我们无法尝试通过关卡并打败时间。

碰撞检测

我们将使用矩形相交和 SFML 相交函数来处理碰撞检测。在这个项目中的不同之处在于,我们将把碰撞检测代码抽象成自己的函数,而 Thomas 和 Bob,正如我们已经看到的,有多个矩形(m_Headm_Feetm_Leftm_Right)需要检查碰撞。

编写 detectCollisions 函数

要清楚,这个函数是 Engine 类的一部分。打开Engine.h文件,并添加一个名为detectCollisions的函数声明。这在以下代码片段中突出显示:

   // Private functions for internal use only 
   void input(); 
   void update(float dtAsSeconds); 
   void draw(); 

   // Load a new level 
   void loadLevel(); 

 // Run will call all the private functions
   bool detectCollisions(PlayableCharacter& character); 

public: 
   // The Engine constructor 
   Engine(); 

从签名中可以看出,detectCollision函数接受一个多态参数,即PlayerCharacter对象。正如我们所知,PlayerCharacter是抽象的,永远不能被实例化。但是,我们可以通过ThomasBob类继承它。我们将能够将m_Thomasm_Bob传递给detectCollisions

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击突出显示(高亮)C++文件( .cpp ,然后在名称字段中键入DetectCollisions.cpp。最后,单击添加按钮。我们现在准备编写detectCollisions函数。

将以下代码添加到DetectCollisions.cpp。请注意,这只是该函数的第一部分:

#include "stdafx.h" 
#include "Engine.h" 

bool Engine::detectCollisions(PlayableCharacter& character) 
{ 
   bool reachedGoal = false; 
   // Make a rect for all his parts 
   FloatRect detectionZone = character.getPosition(); 

   // Make a FloatRect to test each block 
   FloatRect block; 

   block.width = TILE_SIZE; 
   block.height = TILE_SIZE; 

   // Build a zone around thomas to detect collisions 
   int startX = (int)(detectionZone.left / TILE_SIZE) - 1; 
   int startY = (int)(detectionZone.top / TILE_SIZE) - 1; 
   int endX = (int)(detectionZone.left / TILE_SIZE) + 2; 

   // Thomas is quite tall so check a few tiles vertically 
   int endY = (int)(detectionZone.top / TILE_SIZE) + 3; 

   // Make sure we don't test positions lower than zero 
   // Or higher than the end of the array 
   if (startX < 0)startX = 0; 
   if (startY < 0)startY = 0; 
   if (endX >= m_LM.getLevelSize().x) 
      endX = m_LM.getLevelSize().x; 
   if (endY >= m_LM.getLevelSize().y) 
      endY = m_LM.getLevelSize().y; 

首先,我们声明一个名为reachedGoal的布尔值。这是detectCollisions函数返回给调用代码的值。它被初始化为false

接下来,我们声明一个名为detectionZoneFloatRect,并用表示角色精灵整个矩形的相同矩形对其进行初始化。请注意,我们实际上不会使用这个矩形进行交集测试。之后,我们声明另一个名为blockFloatRect。我们将block初始化为一个 50x50 的矩形。我们很快就会看到block的使用。

接下来,我们将看到如何使用detectionZone。我们通过扩展detectionZone周围的区域几个方块来初始化四个int变量startXstartYendXendY。在接下来的四个if语句中,我们检查不可能尝试在不存在的瓦片上进行碰撞检测。我们通过确保我们永远不会检查小于零或大于getLevelSize().x.y返回的值来实现这一点。

之前的所有代码所做的是创建一个用于碰撞检测的区域。在角色远离数百或数千像素的方块上进行碰撞检测是没有意义的。此外,如果我们尝试在数组位置不存在的地方进行碰撞检测(小于零或大于getLevelSize()...),游戏将崩溃。

接下来,添加处理玩家掉出地图的代码:

   // Has the character fallen out of the map? 
   FloatRect level(0, 0,  
      m_LM.getLevelSize().x * TILE_SIZE,  
      m_LM.getLevelSize().y * TILE_SIZE); 

   if (!character.getPosition().intersects(level)) 
   { 
      // respawn the character 
      character.spawn(m_LM.getStartPosition(), GRAVITY); 
   } 

角色要停止下落,必须与平台发生碰撞。因此,如果玩家移出地图(没有平台的地方),它将不断下落。之前的代码检查角色是否FloatRectlevel相交。如果不是,则它已经掉出地图,spawn函数将其发送回起点。

添加以下相当大的代码,然后我们将逐步解释它的作用:

   // Loop through all the local blocks 
   for (int x = startX; x < endX; x++) 
   { 
      for (int y = startY; y < endY; y++) 
      { 
         // Initialize the starting position of the current block 
         block.left = x * TILE_SIZE; 
         block.top = y * TILE_SIZE; 

         // Has character been burnt or drowned? 
         // Use head as this allows him to sink a bit 
         if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3) 
         { 
            if (character.getHead().intersects(block)) 
            { 
               character.spawn(m_LM.getStartPosition(), GRAVITY); 
               // Which sound should be played? 
               if (m_ArrayLevel[y][x] == 2)// Fire, ouch! 
               { 
                  // Play a sound 

               } 
               else // Water 
               { 
                  // Play a sound 
               } 
            } 
         } 

         // Is character colliding with a regular block 
         if (m_ArrayLevel[y][x] == 1) 
         { 

            if (character.getRight().intersects(block)) 
            { 
               character.stopRight(block.left); 
            } 
            else if (character.getLeft().intersects(block)) 
            { 
               character.stopLeft(block.left); 
            } 

            if (character.getFeet().intersects(block)) 
            { 
               character.stopFalling(block.top); 
            } 
            else if (character.getHead().intersects(block)) 
            { 
               character.stopJump(); 
            } 
         } 

         // More collision detection here once we have  
         // learned about particle effects 

         // Has the character reached the goal? 
         if (m_ArrayLevel[y][x] == 4) 
         { 
            // Character has reached the goal 
            reachedGoal = true; 
         } 

      } 

   } 

之前的代码使用相同的技术做了三件事。它循环遍历startXendXstartYendY之间包含的所有值。对于每次循环,它检查并执行以下操作:

  • 角色是否被烧伤或淹死?代码if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)确定当前被检查的位置是否是火瓦或水瓦。如果角色的头与这些瓦片之一相交,玩家将重新生成。我们还在准备在下一章节中添加声音的空的if…else块。

  • 角色是否触碰到普通瓦片?代码if (m_ArrayLevel[y][x] == 1)确定当前被检查的位置是否持有普通瓦片。如果它与表示角色各个身体部位的任何矩形相交,相关的函数将被调用(stopRightstopLeftstopFallingstopJump)。传递给这些函数的值以及函数如何使用这些值重新定位角色是非常微妙的。虽然不需要仔细检查这些值来理解代码,但您可能希望查看传递的值,然后参考上一章节PlayableCharacter类的适当函数。这将帮助您准确理解发生了什么。

  • 角色是否触碰到目标瓦片?这是通过代码if (m_ArrayLevel[y][x] == 4)来确定的。我们只需要将reachedGoal设置为trueEngine类的update函数将跟踪托马斯和鲍勃是否同时达到目标。我们将在update中编写这段代码,就在一分钟内。

将最后一行代码添加到detectCollisions函数中:

   // All done, return, whether or not a new level might be required 
   return reachedGoal; 
} 

前面的代码返回reachedGoal,以便调用代码可以跟踪并适当地响应如果两个角色同时达到目标。

现在我们所需要做的就是每帧每个角色调用一次detectCollision函数。在Update.cpp文件的if(m_Playing)代码块中添加以下突出显示的代码:

if (m_Playing) 
{ 
   // Update Thomas 
   m_Thomas.update(dtAsSeconds); 

   // Update Bob 
   m_Bob.update(dtAsSeconds); 

 // Detect collisions and see if characters
   // have reached the goal tile
   // The second part of the if condition is only executed
   // when thomas is touching the home tile
   if (detectCollisions(m_Thomas) && detectCollisions(m_Bob))
   {
     // New level required
     m_NewLevelRequired = true;
     // Play the reach goal sound
   }
   else
   {
     // Run bobs collision detection
     detectCollisions(m_Bob);
   } 

   // Count down the time the player has left 
   m_TimeRemaining -= dtAsSeconds; 

   // Have Thomas and Bob run out of time? 
   if (m_TimeRemaining <= 0) 
   { 
      m_NewLevelRequired = true; 
   } 

}// End if playing 

前面的代码调用detectCollision函数并检查鲍勃和托马斯是否同时达到目标。如果是,下一关将通过将m_NewLevelRequired设置为true来准备好。

你可以运行游戏并走在平台上。你可以达到目标并开始新的关卡。此外,第一次,跳跃按钮(W或箭头上)将起作用。

如果你达到目标,下一关将加载。如果你达到最后一关的目标,那么第一关将以减少 10%的时间限制加载。当然,由于我们还没有构建 HUD,所以没有时间或当前关卡的视觉反馈。我们将在下一章中完成。

然而,许多关卡需要托马斯和鲍勃作为一个团队合作。更具体地说,托马斯和鲍勃需要能够爬到彼此的头上。

更多碰撞检测

Update.cpp文件中添加前面添加的代码后面,if (m_Playing)部分内添加以下代码:

if (m_Playing) 
{ 
   // Update Thomas 
   m_Thomas.update(dtAsSeconds); 

   // Update Bob 
   m_Bob.update(dtAsSeconds); 

   // Detect collisions and see if characters
   // have reached the goal tile 
   // The second part of the if condition is only executed 
   // when thomas is touching the home tile 
   if (detectCollisions(m_Thomas) && detectCollisions(m_Bob)) 
   { 
      // New level required 
      m_NewLevelRequired = true; 

      // Play the reach goal sound 

   } 
   else 
   { 
      // Run bobs collision detection 
      detectCollisions(m_Bob); 
   } 

 // Let bob and thomas jump on each others heads
   if (m_Bob.getFeet().intersects(m_Thomas.getHead()))
   {
     m_Bob.stopFalling(m_Thomas.getHead().top);
   }
   else if (m_Thomas.getFeet().intersects(m_Bob.getHead()))
   {
     m_Thomas.stopFalling(m_Bob.getHead().top);
   } 

   // Count down the time the player has left 
   m_TimeRemaining -= dtAsSeconds; 

   // Have Thomas and Bob run out of time? 
   if (m_TimeRemaining <= 0) 
   { 
      m_NewLevelRequired = true; 
   } 

}// End if playing 

你可以再次运行游戏并站在托马斯和鲍勃的头上,以到达以前无法到达的难以到达的地方:

更多碰撞检测

摘要

本章中有相当多的代码。我们学会了如何从文件中读取并将文本字符串转换为 char,然后转换为int。一旦我们有了一个二维数组的int,我们就能够填充一个VertexArray来实际显示屏幕上的关卡。然后我们使用完全相同的二维数组 int 来实现碰撞检测。我们使用矩形交集,就像我们在僵尸竞技场项目中所做的那样,尽管这次,为了更精确,我们给每个角色四个碰撞区域,分别代表他们的头部、脚部和左右两侧。

现在游戏完全可玩,我们需要在屏幕上表示游戏的状态(得分和时间)。在下一章中,我们将实现 HUD,以及比我们目前使用的更高级的音效。

第十五章:声音空间化和 HUD

在本章中,我们将添加所有的音效和 HUD。我们在之前的两个项目中都做过这个,但这次我们会以稍微不同的方式来做。我们将探讨声音空间化的概念以及 SFML 如何使这个本来复杂的概念变得简单易行;此外,我们将构建一个 HUD 类来封装将信息绘制到屏幕上的代码。

我们将按照以下顺序完成这些任务:

  • 什么是空间化?

  • SFML 如何处理空间化

  • 构建一个SoundManager

  • 部署发射器

  • 使用SoundManager

  • 构建一个HUD

  • 使用HUD

什么是空间化?

空间化是使某物相对于其所在的空间或内部的行为。在我们的日常生活中,自然界中的一切默认都是空间化的。如果一辆摩托车从左到右呼啸而过,我们会听到声音从一侧变得微弱到响亮,当它经过时,它会在另一只耳朵中变得更加显著,然后再次消失在远处。如果有一天早上醒来,世界不再是空间化的,那将是异常奇怪的。

如果我们能让我们的视频游戏更像现实世界,我们的玩家就能更加沉浸其中。如果玩家能在远处微弱地听到僵尸的声音,而当它们靠近时,它们的不人道的哀嚎声会从一个方向或另一个方向变得更响亮,我们的僵尸游戏会更有趣。

很明显,空间化的数学将会很复杂。我们如何计算特定扬声器中的声音有多大声,基于声音来自的方向以及听者(声音的听者)到发出声音的物体(发射器)的距离?

幸运的是,SFML 为我们处理了所有复杂的事情。我们只需要熟悉一些技术术语,然后就可以开始使用 SFML 来对我们的音效进行空间化。

发射器、衰减和听众

为了让 SFML 能够正常工作,我们需要了解一些信息。我们需要知道声音在我们的游戏世界中来自哪里。这个声音的来源被称为发射器。在游戏中,发射器可以是僵尸、车辆,或者在我们当前的项目中,是一个火焰图块。我们已经在游戏中跟踪了物体的位置,所以给 SFML 发射器位置将会非常简单。

我们需要意识到的下一个因素是衰减。衰减是波动恶化的速率。你可以简化这个说法,并将其具体化为声音,即衰减是声音减小的速度。这在技术上并不准确,但对于本章的目的来说,这已经足够好了。

我们需要考虑的最后一个因素是听众。当 SFML 对声音进行空间化时,它是相对于什么进行空间化的?在大多数游戏中,合理的做法是使用玩家角色。在我们的游戏中,我们将使用 Thomas。

SFML 如何处理空间化

SFML 有许多函数可以让我们处理发射器、衰减和听众。让我们先假设一下,然后我们将编写一些代码,真正为我们的项目添加空间化声音。

我们可以设置一个准备播放的音效,就像我们经常做的那样,如下所示:

// Declare SoundBuffer in the usual way 
SoundBuffer zombieBuffer; 
// Declare a Sound object as-per-usual 
Sound zombieSound; 
// Load the sound from a file like we have done so often 
zombieBuffer.loadFromFile("sound/zombie_growl.wav"); 
// Associate the Sound object with the Buffer 
zombieSound.setBuffer(zombieBuffer); 

我们可以使用setPosition函数来设置发射器的位置,如下面的代码所示:

// Set the horizontal and vertical positions of the emitter 
// In this case the emitter is a zombie 
// In the Zombie Arena project we could have used  
// getPosition().x and getPosition().y 
// These values are arbitrary 
float x = 500; 
float y = 500; 
zombieSound.setPosition(x, y, 0.0f); 

如前面的代码注释中建议的,你如何获取发射器的坐标可能取决于游戏的类型。就像在 Zombie Arena 项目中所示的那样,这将是非常简单的。当我们在这个项目中设置位置时,我们将面临一些挑战。

我们可以使用以下代码设置衰减级别:

zombieSound.setAttenuation(15); 

实际的衰减级别可能有些模糊。您希望玩家得到的效果可能与基于衰减的距离减小音量的准确科学公式不同。获得正确的衰减级别通常是通过实验来实现的。一般来说,衰减级别越高,声音级别降至静音的速度就越快。

此外,您可能希望在发射器周围设置一个音量完全不衰减的区域。如果该功能在一定范围之外不合适,或者您有大量的声源并且不想过度使用该功能,您可以这样做。为此,我们可以使用setMinimumDistance函数,如下所示:

zombieSound.setMinDistance(150); 

通过上一行代码,衰减直到听众离发射器150像素/单位远才开始计算。

SFML 库中的一些其他有用函数包括setLoop函数。当传入 true 作为参数时,此函数将告诉 SFML 在循环播放声音时保持播放,如下面的代码所示:

zombieSound.setLoop(true); 

声音将继续播放,直到我们使用以下代码结束它:

zombieSound.stop(); 

不时地,我们会想要知道声音的状态(正在播放或已停止)。我们可以使用getStatus函数来实现这一点,如下面的代码所示:

if (zombieSound.getStatus() == Sound::Status::Stopped) 
{ 
   // The sound is NOT playing 
   // Take whatever action here 
} 

if (zombieSound.getStatus() == Sound::Status::Playing) 
{ 
   // The sound IS playing 
   // Take whatever action here 
} 

在使用 SFML 进行声音空间化的最后一个方面我们需要涵盖的是听众在哪里?我们可以使用以下代码设置听众的位置:

// Where is the listener?  
// How we get the values of x and y varies depending upon the game 
// In the Zombie Arena game or the Thomas Was Late game 
// We can use getPosition() 
Listener::setPosition(m_Thomas.getPosition().x,  
   m_Thomas.getPosition().y, 0.0f); 

上述代码将使所有声音相对于该位置播放。这正是我们需要的远处火瓦或迫近的僵尸的咆哮声,但对于像跳跃这样的常规音效来说,这是一个问题。我们可以开始处理一个发射器来定位玩家的位置,但 SFML 为我们简化了事情。每当我们想播放正常声音时,我们只需调用setRelativeToListener,如下面的代码所示,然后以与迄今为止完全相同的方式播放声音。以下是我们可能播放正常、非空间化跳跃音效的方式:

jumpSound.setRelativeToListener(true); 
jumpSound.play(); 

我们只需要在播放任何空间化声音之前再次调用Listener::setPosition

现在我们有了广泛的 SFML 声音函数,我们准备制作一些真正的空间化噪音。

构建 SoundManager 类

您可能还记得在上一个项目中,所有的声音代码占用了相当多的行数。现在考虑到空间化,它将变得更长。为了使我们的代码易于管理,我们将编写一个类来管理所有声音效果的播放。此外,为了帮助我们进行空间化,我们还将向 Engine 类添加一个函数,但我们将在后面的章节讨论。

编写 SoundManager.h

让我们开始编写和检查头文件。

解决方案资源管理器中右键单击标头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击标头文件( .h ,然后在名称字段中键入SoundManager.h。最后,单击添加按钮。现在我们准备为SoundManager类编写头文件。

添加并检查以下代码:

#pragma once 
#include <SFML/Audio.hpp> 

using namespace sf; 

class SoundManager 
{ 
   private: 
      // The buffers 
      SoundBuffer m_FireBuffer; 
      SoundBuffer m_FallInFireBuffer; 
      SoundBuffer m_FallInWaterBuffer; 
      SoundBuffer m_JumpBuffer; 
      SoundBuffer m_ReachGoalBuffer; 

      // The Sounds 
      Sound m_Fire1Sound; 
      Sound m_Fire2Sound; 
      Sound m_Fire3Sound; 
      Sound m_FallInFireSound; 
      Sound m_FallInWaterSound; 
      Sound m_JumpSound; 
      Sound m_ReachGoalSound; 

      // Which sound should we use next, fire 1, 2 or 3 
      int m_NextSound = 1; 

   public: 

      SoundManager(); 

      void playFire(Vector2f emitterLocation,  
         Vector2f listenerLocation); 

      void playFallInFire(); 
      void playFallInWater(); 
      void playJump(); 
      void playReachGoal(); 
}; 

在我们刚刚添加的代码中没有什么棘手的地方。有五个SoundBuffer对象和八个Sound对象。其中三个Sound对象将播放相同的SoundBuffer。这解释了不同数量的Sound/SoundBuffer对象的原因。我们这样做是为了能够同时播放多个咆哮声效,并具有不同的空间化参数。

请注意,有一个m_NextSound变量,将帮助我们跟踪这些潜在同时发生的声音中我们应该下一个使用哪一个。

有一个构造函数SoundManager,在那里我们将设置所有的音效,还有五个函数将播放音效。其中四个函数只是简单地播放普通音效,它们的代码将非常简单。

其中一个函数playFire将处理空间化的音效,并且会更加深入。注意playFire函数的参数。它接收一个Vector2f,这是发射器的位置,和第二个Vector2f,这是听众的位置。

编写 SoundManager.cpp 文件

现在我们可以编写函数定义了。构造函数和playFire函数有相当多的代码,所以我们将分别查看它们。其他函数都很简短,所以我们将一次处理它们。

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件(.cpp,然后在名称字段中键入SoundManager.cpp。最后,单击添加按钮。现在我们准备好为SoundManager类编写.cpp文件了。

编写构造函数

SoundManager.cpp添加以下包含指令和构造函数的代码:

#include "stdafx.h" 
#include "SoundManager.h" 
#include <SFML/Audio.hpp> 

using namespace sf; 

SoundManager::SoundManager() 
{ 
   // Load the sound in to the buffers 
   m_FireBuffer.loadFromFile("sound/fire1.wav"); 
   m_FallInFireBuffer.loadFromFile("sound/fallinfire.wav"); 
   m_FallInWaterBuffer.loadFromFile("sound/fallinwater.wav"); 
   m_JumpBuffer.loadFromFile("sound/jump.wav"); 
   m_ReachGoalBuffer.loadFromFile("sound/reachgoal.wav"); 

   // Associate the sounds with the buffers 
   m_Fire1Sound.setBuffer(m_FireBuffer); 
   m_Fire2Sound.setBuffer(m_FireBuffer); 
   m_Fire3Sound.setBuffer(m_FireBuffer); 
   m_FallInFireSound.setBuffer(m_FallInFireBuffer); 
   m_FallInWaterSound.setBuffer(m_FallInWaterBuffer); 
   m_JumpSound.setBuffer(m_JumpBuffer); 
   m_ReachGoalSound.setBuffer(m_ReachGoalBuffer); 

   // When the player is 50 pixels away sound is full volume 
   float minDistance = 150; 
   // The sound reduces steadily as the player moves further away 
   float attenuation = 15; 

   // Set all the attenuation levels 
   m_Fire1Sound.setAttenuation(attenuation); 
   m_Fire2Sound.setAttenuation(attenuation); 
   m_Fire3Sound.setAttenuation(attenuation); 

   // Set all the minimum distance levels 
   m_Fire1Sound.setMinDistance(minDistance); 
   m_Fire2Sound.setMinDistance(minDistance); 
   m_Fire3Sound.setMinDistance(minDistance); 

   // Loop all the fire sounds 
   // when they are played 
   m_Fire1Sound.setLoop(true); 
   m_Fire2Sound.setLoop(true); 
   m_Fire3Sound.setLoop(true); 
} 

在前面的代码中,我们将五个声音文件加载到五个SoundBuffer对象中。接下来,我们将八个Sound对象与其中一个SoundBuffer对象关联起来。请注意,m_Fire1Soundm_Fire2Soundm_Fire3Sound都将从同一个SoundBufferm_FireBuffer中播放。

接下来,我们设置了三个火焰声音的衰减和最小距离。

提示

通过实验得出了分别为15015的值。一旦游戏运行起来,我鼓励你通过改变这些值来进行实验,看(或者说听)听到的差异。

最后,对于构造函数,我们在每个与火相关的Sound对象上使用了setLoop函数。现在当我们调用play时,它们将持续播放。

编写 playFire 函数

添加下面代码中显示的playFire函数,然后我们可以讨论它:

void SoundManager::playFire( 
   Vector2f emitterLocation, Vector2f listenerLocation) 
{ 
   // Where is the listener? Thomas. 
   Listener::setPosition(listenerLocation.x,  
      listenerLocation.y, 0.0f); 

   switch(m_NextSound) 
   { 

   case 1: 
      // Locate/move the source of the sound 
      m_Fire1Sound.setPosition(emitterLocation.x,  
         emitterLocation.y, 0.0f); 

      if (m_Fire1Sound.getStatus() == Sound::Status::Stopped) 
      { 
         // Play the sound, if its not already 
         m_Fire1Sound.play(); 
      } 
      break; 

   case 2: 
      // Do the same as previous for the second sound 
      m_Fire2Sound.setPosition(emitterLocation.x,  
         emitterLocation.y, 0.0f); 

      if (m_Fire2Sound.getStatus() == Sound::Status::Stopped) 
      { 
         m_Fire2Sound.play(); 
      } 
      break; 

   case 3: 
      // Do the same as previous for the third sound 
      m_Fire3Sound.setPosition(emitterLocation.x,  
         emitterLocation.y, 0.0f); 

      if (m_Fire3Sound.getStatus() == Sound::Status::Stopped) 
      { 
         m_Fire3Sound.play(); 
      } 
      break; 
   } 

   // Increment to the next fire sound 
   m_NextSound++; 

   // Go back to 1 when the third sound has been started 
   if (m_NextSound > 3) 
   { 
      m_NextSound = 1; 
   } 
} 

我们要做的第一件事是调用Listener::setPosition,并根据作为参数传入的Vector2f设置听众的位置。

接下来,代码根据m_NextSound的值进入了一个switch块。每个case语句都做了完全相同的事情,但是针对m_Fire1Soundm_Fire2Soundm_Fire3Sound

在每个case块中,我们使用传入的参数调用setPosition函数来设置发射器的位置。每个case块中代码的下一部分检查音效当前是否已停止,如果是,则播放音效。我们很快就会看到如何得到传递到这个函数中的发射器和听众的位置。

playFire函数的最后部分增加了m_NextSound,并确保它只能等于 1、2 或 3,这是switch块所需的。

编写其余的 SoundManager 函数

添加这四个简单的函数:

void SoundManager::playFallInFire() 
{ 
   m_FallInFireSound.setRelativeToListener(true); 
   m_FallInFireSound.play(); 
} 

void SoundManager::playFallInWater() 
{ 
   m_FallInWaterSound.setRelativeToListener(true); 
   m_FallInWaterSound.play(); 
} 

void SoundManager::playJump() 
{ 
   m_JumpSound.setRelativeToListener(true); 
   m_JumpSound.play(); 
} 

void SoundManager::playReachGoal() 
{ 
   m_ReachGoalSound.setRelativeToListener(true); 
   m_ReachGoalSound.play(); 
} 

playFallInFireplayFallInWaterplayReachGoal函数只做两件事。首先,它们各自调用setRelativeToListener,所以音效不是空间化的,使音效变得普通,而不是定向的,然后它们调用适当的Sound对象上的play

这就结束了SoundManager类。现在我们可以在Engine类中使用它。

将 SoundManager 添加到游戏引擎

打开Engine.h文件,并添加一个新的SoundManager类的实例,如下面突出显示的代码所示:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "TextureHolder.h" 
#include "Thomas.h" 
#include "Bob.h" 
#include "LevelManager.h" 
#include "SoundManager.h" 

using namespace sf; 

class Engine 
{ 
private: 
   // The texture holder 
   TextureHolder th; 

   // Thomas and his friend, Bob 
   Thomas m_Thomas; 
   Bob m_Bob; 

   // A class to manage all the levels 
   LevelManager m_LM; 

 // Create a SoundManager
   SoundManager m_SM; 

   const int TILE_SIZE = 50; 
   const int VERTS_IN_QUAD = 4; 

在这一点上,我们可以使用m_SM来调用各种play...函数。不幸的是,仍然有一些工作要做,以便管理发射器(火砖)的位置。

填充声音发射器

打开Engine.h文件,并为populateEmitters函数添加一个新的原型和一个新的 STLvectorVector2f对象:

   ... 
   ... 
   ... 
   // Run will call all the private functions 
   bool detectCollisions(PlayableCharacter& character); 

 // Make a vector of the best places to emit sounds from
   void populateEmitters(vector <Vector2f>& vSoundEmitters,
     int** arrayLevel);

   // A vector of Vector2f for the fire emitter locations
   vector <Vector2f> m_FireEmitters; 

public: 
   ... 
   ... 
   ... 

populateEmitters函数以Vector2f对象的vector和指向int的指针作为参数。这个vector将保存每个发射器在一个级别中的位置,而数组是我们的二维数组,它保存了一个级别的布局。

编写 populateEmitters 函数

populateEmitters函数的工作是扫描arrayLevel的所有元素,并决定在哪里放置发射器。它将其结果存储在m_FireEmitters中。

在“解决方案资源管理器”中右键单击“源文件”,然后选择“添加”|“新项目”。在“添加新项目”窗口中,选择(通过左键单击)“C++文件”(.cpp),然后在“名称”字段中输入PopulateEmitters.cpp。最后,单击“添加”按钮。现在我们可以编写新函数populateEmitters

添加完整的代码;确保在编写代码时仔细研究代码,然后我们可以讨论它。

#include "stdafx.h" 
#include "Engine.h" 

using namespace sf; 
using namespace std; 

void Engine::populateEmitters( 
   vector <Vector2f>& vSoundEmitters, int** arrayLevel) 
{ 

   // Make sure the vector is empty 
   vSoundEmitters.empty(); 

   // Keep track of the previous emitter 
   // so we don't make too many 
   FloatRect previousEmitter; 

   // Search for fire in the level 
   for (int x = 0; x < (int)m_LM.getLevelSize().x; x++) 
   { 
      for (int y = 0; y < (int)m_LM.getLevelSize().y; y++) 
      { 
         if (arrayLevel[y][x] == 2)// fire is present 
         { 
            // Skip over any fire tiles too  
            // near a previous emitter 
            if (!FloatRect(x * TILE_SIZE, 
               y * TILE_SIZE, 
               TILE_SIZE, 
               TILE_SIZE).intersects(previousEmitter)) 
            { 
               // Add the coordiantes of this water block 
               vSoundEmitters.push_back( 
                  Vector2f(x * TILE_SIZE, y * TILE_SIZE)); 

               // Make a rectangle 6 blocks x 6 blocks, 
               // so we don't make any more emitters  
               // too close to this one 
               previousEmitter.left = x * TILE_SIZE; 
               previousEmitter.top = y * TILE_SIZE; 
               previousEmitter.width = TILE_SIZE * 6; 
               previousEmitter.height = TILE_SIZE * 6; 
            } 

         } 

      } 

   } 
   return; 

} 

一些代码乍一看可能会很复杂。理解我们用来选择发射器位置的技术将使其变得更简单。在我们的级别中,通常会有大块的火砖。在我设计的一个级别中,有超过 30 个火砖。代码确保在给定的矩形内只有一个发射器。这个矩形存储在previousEmitter中,大小为 300 像素乘以 300 像素(TILE_SIZE * 6)。

该代码设置了一个嵌套的for循环,循环遍历arrayLevel以寻找火砖。当找到一个时,它确保它不与previousEmitter相交。只有在这种情况下,它才使用pushBack函数向vSoundEmitters添加另一个发射器。在这样做之后,它还更新previousEmitter以避免得到大量的声音发射器。

让我们制造一些噪音。

播放声音

打开LoadLevel.cpp文件,并在以下代码中添加对新的populateEmitters函数的调用:

void Engine::loadLevel() 
{ 
   m_Playing = false; 

   // Delete the previously allocated memory 
   for (int i = 0; i < m_LM.getLevelSize().y; ++i) 
   { 
      delete[] m_ArrayLevel[i]; 

   } 
   delete[] m_ArrayLevel; 

   // Load the next 2d array with the map for the level 
   // And repopulate the vertex array as well 
   m_ArrayLevel = m_LM.nextLevel(m_VALevel); 

 // Prepare the sound emitters
   populateEmitters(m_FireEmitters, m_ArrayLevel); 

   // How long is this new time limit 
   m_TimeRemaining = m_LM.getTimeLimit(); 

   // Spawn Thomas and Bob 
   m_Thomas.spawn(m_LM.getStartPosition(), GRAVITY); 
   m_Bob.spawn(m_LM.getStartPosition(), GRAVITY); 

   // Make sure this code isn't run again 
   m_NewLevelRequired = false; 
} 

要添加的第一个声音是跳跃声音。您可能记得键盘处理代码在BobThomas类的纯虚函数中,而handleInput函数在成功启动跳跃时返回true

打开Input.cpp文件,并添加高亮代码行,以在 Thomas 或 Bob 成功开始跳跃时播放跳跃声音。

// Handle input specific to Thomas 
if (m_Thomas.handleInput()) 
{ 
   // Play a jump sound 
 m_SM.playJump(); 
} 

// Handle input specific to Bob 
if (m_Bob.handleInput()) 
{ 
   // Play a jump sound 
 m_SM.playJump(); 
} 

打开Update.cpp文件,并添加高亮代码行,以在 Thomas 和 Bob 同时到达当前级别的目标时播放成功的声音。

// Detect collisions and see if characters have reached the goal tile 
// The second part of the if condition is only executed 
// when thomas is touching the home tile 
if (detectCollisions(m_Thomas) && detectCollisions(m_Bob)) 
{ 
   // New level required 
   m_NewLevelRequired = true; 

   // Play the reach goal sound 
 m_SM.playReachGoal(); 

} 
else 
{ 
   // Run bobs collision detection 
   detectCollisions(m_Bob); 
} 

同样在Update.cpp文件中,我们将添加代码来循环遍历m_FireEmitters向量,并决定何时需要调用SoundManager类的playFire函数。

仔细观察新的高亮代码周围的一小部分上下文。在恰当的位置添加这段代码是至关重要的。

}// End if playing 

// Check if a fire sound needs to be played
vector<Vector2f>::iterator it;

// Iterate through the vector of Vector2f objects
for (it = m_FireEmitters.begin();it != m_FireEmitters.end(); it++)
{
   // Where is this emitter?
   // Store the location in pos
   float posX = (*it).x;
   float posY = (*it).y;
   // is the emiter near the player?
   // Make a 500 pixel rectangle around the emitter
   FloatRect localRect(posX - 250, posY - 250, 500, 500);

   // Is the player inside localRect?
   if (m_Thomas.getPosition().intersects(localRect))
   {
     // Play the sound and pass in the location as well
     m_SM.playFire(Vector2f(posX, posY), m_Thomas.getCenter());
   }
} 

// Set the appropriate view around the appropriate character 

前面的代码有点像声音的碰撞检测。每当 Thomas 停留在围绕火砖发射器的 500x500 像素矩形内时,playFire函数就会被调用,传入发射器和 Thomas 的坐标。然后playFire函数会完成其余的工作,并触发一个空间化的循环声音效果。

打开DetectCollisions.cpp文件,找到适当的位置,并按照以下所示添加高亮代码。两行高亮代码触发了当角色掉入水或火砖时播放声音效果。

// Has character been burnt or drowned? 
// Use head as this allows him to sink a bit 
if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3) 
{ 
   if (character.getHead().intersects(block)) 
   { 
      character.spawn(m_LM.getStartPosition(), GRAVITY); 
      // Which sound should be played? 
      if (m_ArrayLevel[y][x] == 2)// Fire, ouch! 
      { 
        // Play a sound 
 m_SM.playFallInFire(); 

      } 
      else // Water 
      { 
        // Play a sound 
 m_SM.playFallInWater(); 
      } 
   } 
} 

玩游戏将允许您听到所有的声音,包括在靠近火砖时的很酷的空间化。

HUD 类

HUD 是超级简单的,与书中的其他两个项目没有什么不同。我们要做的不同之处在于将所有代码封装在一个新的 HUD 类中。如果我们将所有的字体、文本和其他变量声明为这个新类的成员,然后在构造函数中初始化它们,并为它们提供 getter 函数,这将使得Engine类清除了大量的声明和初始化。

编写 HUD.h

首先,我们将编写HUD.h文件,其中包含所有成员变量和函数声明。在解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件.h)并在名称字段中键入HUD.h。最后,单击添加按钮。现在我们准备为HUD类编写头文件。

将以下代码添加到HUD.h中:

#pragma once 
#include <SFML/Graphics.hpp> 

using namespace sf; 

class Hud 
{ 
private: 
   Font m_Font; 
   Text m_StartText; 
   Text m_TimeText; 
   Text m_LevelText; 

public: 
   Hud(); 
   Text getMessage(); 
   Text getLevel(); 
   Text getTime(); 

   void setLevel(String text); 
   void setTime(String text); 
}; 

在先前的代码中,我们添加了一个Font实例和三个Text实例。Text对象将用于显示提示用户开始、剩余时间和当前级别编号的消息。

公共函数更有趣。首先是构造函数,大部分代码将在其中。构造函数将初始化FontText对象,并将它们相对于当前屏幕分辨率定位在屏幕上。

三个 getter 函数,getMessagegetLevelgetTime将返回一个Text对象给调用代码,以便能够将它们绘制到屏幕上。

setLevelsetTime函数将用于更新显示在m_LevelTextm_TimeText中的文本。

现在我们可以编写刚刚概述的所有函数的定义。

编写 HUD.cpp 文件

解决方案资源管理器中右键单击源文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击C++文件( .cpp 并在名称字段中键入HUD.cpp。最后,单击添加按钮。现在我们准备为HUD类编写.cpp文件。

添加包含指令和以下代码,然后我们将讨论它:

#include "stdafx.h" 
#include "Hud.h" 

Hud::Hud() 
{ 
   Vector2u resolution; 
   resolution.x = VideoMode::getDesktopMode().width; 
   resolution.y = VideoMode::getDesktopMode().height; 

   // Load the font 
   m_Font.loadFromFile("fonts/Roboto-Light.ttf"); 

   // when Paused 
   m_StartText.setFont(m_Font); 
   m_StartText.setCharacterSize(100); 
   m_StartText.setFillColor(Color::White); 
   m_StartText.setString("Press Enter when ready!"); 

   // Position the text 
   FloatRect textRect = m_StartText.getLocalBounds(); 

   m_StartText.setOrigin(textRect.left + 
      textRect.width / 2.0f, 
      textRect.top + 
      textRect.height / 2.0f); 

   m_StartText.setPosition( 
      resolution.x / 2.0f, resolution.y / 2.0f); 

   // Time 
   m_TimeText.setFont(m_Font); 
   m_TimeText.setCharacterSize(75); 
   m_TimeText.setFillColor(Color::White); 
   m_TimeText.setPosition(resolution.x - 150, 0); 
   m_TimeText.setString("------"); 

   // Level 
   m_LevelText.setFont(m_Font); 
   m_LevelText.setCharacterSize(75); 
   m_LevelText.setFillColor(Color::White); 
   m_LevelText.setPosition(25, 0); 
   m_LevelText.setString("1"); 
} 

首先,我们将水平和垂直分辨率存储在名为resolutionVector2u中。接下来,我们从我们在第十二章中添加的fonts目录中加载字体。抽象和代码管理 - 更好地利用面向对象编程

接下来的四行代码设置了m_StartText的字体、颜色、大小和文本。此后的代码块捕获了包裹m_StartText的矩形的大小,并进行计算以确定如何将其居中放置在屏幕上。如果您想对代码的这部分进行更详细的解释,请参考第三章:C++字符串、SFML 时间 - 玩家输入和 HUD

构造函数中的最后两个代码块设置了m_TimeTextm_LevelText的字体、文本大小、颜色、位置和实际文本。然而,我们很快会看到,这两个Text对象将通过两个 setter 函数进行更新,每当需要时。

在我们刚刚添加的代码之后立即添加以下 getter 和 setter 函数:

Text Hud::getMessage() 
{ 
   return m_StartText; 
} 

Text Hud::getLevel() 
{ 
   return m_LevelText; 
} 

Text Hud::getTime() 
{ 
   return m_TimeText; 
} 

void Hud::setLevel(String text) 
{ 
   m_LevelText.setString(text); 
} 

void Hud::setTime(String text) 
{ 
   m_TimeText.setString(text); 
} 

先前代码中的前三个函数只是返回适当的Text对象,m_StartTextm_LevelTextm_TimeText。我们将很快使用这些函数,在屏幕上绘制 HUD 时。最后两个函数,setLevelsetTime,使用setString函数来更新适当的Text对象,该值将从Engine类的update函数中每 500 帧传入。

完成所有这些后,我们可以在游戏引擎中使用 HUD 类。

使用 HUD 类

打开Engine.h,添加一个包含我们新类的声明,声明一个新的HUD类的实例,并声明并初始化两个新的成员变量,用于跟踪我们更新 HUD 的频率。正如我们在之前的两个项目中学到的,我们不需要为每一帧都这样做。

将突出显示的代码添加到Engine.h中:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "TextureHolder.h" 
#include "Thomas.h" 
#include "Bob.h" 
#include "LevelManager.h" 
#include "SoundManager.h" 
#include "HUD.h" 

using namespace sf; 

class Engine 
{ 
private: 
   // The texture holder 
   TextureHolder th; 

   // Thomas and his friend, Bob 
   Thomas m_Thomas; 
   Bob m_Bob; 

   // A class to manage all the levels 
   LevelManager m_LM; 

   // Create a SoundManager 
   SoundManager m_SM; 

 // The Hud   Hud m_Hud;
   int m_FramesSinceLastHUDUpdate = 0;
   int m_TargetFramesPerHUDUpdate = 500; 

   const int TILE_SIZE = 50; 

接下来,我们需要在Engine类的update函数中添加一些代码。打开Update.cpp并添加突出显示的代码以每 500 帧更新 HUD:

   // Set the appropriate view around the appropriate character 
   if (m_SplitScreen) 
   { 
      m_LeftView.setCenter(m_Thomas.getCenter()); 
      m_RightView.setCenter(m_Bob.getCenter()); 
   } 
   else 
   { 
      // Centre full screen around appropriate character 
      if (m_Character1) 
      { 
         m_MainView.setCenter(m_Thomas.getCenter()); 
      } 
      else 
      { 
         m_MainView.setCenter(m_Bob.getCenter()); 
      } 
   } 

 // Time to update the HUD?
   // Increment the number of frames since the last HUD calculation
   m_FramesSinceLastHUDUpdate++;

   // Update the HUD every m_TargetFramesPerHUDUpdate frames
   if (m_FramesSinceLastHUDUpdate > m_TargetFramesPerHUDUpdate)
   {
     // Update game HUD text
     stringstream ssTime;
     stringstream ssLevel; 
     // Update the time text 
     ssTime << (int)m_TimeRemaining;
     m_Hud.setTime(ssTime.str());
     // Update the level text
     ssLevel << "Level:" << m_LM.getCurrentLevel();
     m_Hud.setLevel(ssLevel.str());
     m_FramesSinceLastHUDUpdate = 0;
   } 
}// End of update function 

在前面的代码中,m_FramesSinceLastUpdate在每帧递增。当m_FramesSinceLastUpdate超过m_TargetFramesPerHUDUpdate时,执行进入if块。在if块内部,我们使用stringstream对象来更新我们的Text,就像在之前的项目中所做的那样。然而,正如你可能期望的那样,在这个项目中我们使用了HUD类,所以我们调用setTimesetLevel函数,传入Text对象需要设置的当前值。

if块中的最后一步是将m_FramesSinceLastUpdate设置回零,这样它就可以开始计算下一个更新。

最后,打开Draw.cpp文件,并添加突出显示的代码来每帧绘制 HUD。

   else 
   { 
      // Split-screen view is active 

      // First draw Thomas' side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGLeftView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_LeftView 
      m_Window.setView(m_LeftView); 

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Now draw Bob's side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGRightView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_RightView 
      m_Window.setView(m_RightView); 

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw bob 
      m_Window.draw(m_Bob.getSprite()); 

   } 

   // Draw the HUD 
   // Switch to m_HudView 
   m_Window.setView(m_HudView); 
 m_Window.draw(m_Hud.getLevel());
   m_Window.draw(m_Hud.getTime());
   if (!m_Playing)
   {
     m_Window.draw(m_Hud.getMessage());
   } 
   // Show everything we have just drawn 
   m_Window.display(); 
}// End of draw 

前面的代码使用 HUD 类的 getter 函数来绘制 HUD。请注意,只有在游戏当前未进行时(!m_Playing)才会调用绘制提示玩家开始的消息。

运行游戏并玩几个关卡,看时间倒计时和关卡增加。当你再次回到第一关时,注意你的时间比之前少了 10%。

总结

我们的《Thomas Was Late》游戏不仅可以完全玩得了,还有定向音效和简单但信息丰富的 HUD,而且我们还可以轻松添加新的关卡。在这一点上,我们可以说它已经完成了。

增加一些闪光效果会很好。在接下来的章节中,我们将探讨两个游戏概念。首先,我们将研究粒子系统,这是我们如何处理爆炸或其他特殊效果的方法。为了实现这一点,我们需要学习更多的 C++知识,看看我们如何彻底重新思考我们的游戏代码结构。

之后,当我们学习 OpenGL 和可编程图形管线时,我们将为游戏添加最后的点睛之笔。然后,我们将有机会涉足GLSL语言,这使我们能够编写直接在 GPU 上执行的代码,以创建一些特殊效果。

第十六章:扩展 SFML 类,粒子系统和着色器

在这最后一章中,我们将探讨 C++中扩展他人类的概念。更具体地说,我们将看看 SFML Drawable类以及将其用作我们自己类的基类的好处。我们还将浅尝 OpenGL 着色器的主题,并看看如何使用另一种语言OpenGL 着色语言GLSL)编写代码,可以直接在图形卡上运行,可以产生可能无法实现的平滑图形效果。像往常一样,我们还将利用我们的新技能和知识来增强当前项目。

以下是我们将按顺序涵盖的主题列表:

  • SFML Drawable 类

  • 构建一个粒子系统

  • OpenGL 着色器和 GLSL

  • 在 Thomas Was Late 游戏中使用着色器

SFML Drawable 类

Drawable类只有一个函数。它也没有变量。此外,它唯一的功能是纯虚拟的。这意味着如果我们从Drawable继承,我们必须实现它唯一的功能。这个目的,你可能还记得来自第十二章的抽象和代码管理-更好地利用 OOP,就是我们可以使用继承自drawable的类作为多态类型。更简单地说,任何 SFML 允许我们对Drawable对象做的事情,我们都可以用继承自它的类来做。唯一的要求是我们必须为纯虚拟函数draw提供定义。

一些从Drawable继承的类已经包括SpriteVertexArray(还有其他)。每当我们使用SpriteVertexArray时,我们都将它们传递给RenderWindow类的draw函数。

我们之所以能够绘制本书中绘制的每个对象,是因为它们都继承自Drawable。我们可以利用这个知识。

我们可以用任何我们喜欢的对象从Drawable继承,只要我们实现纯虚拟的draw函数。这也是一个简单的过程。假设从Drawable继承的SpaceShip类的头文件(SpaceShip.h)将如下所示:

class SpaceShip : public Drawable 
{ 
private: 
   Sprite m_Sprite; 
   // More private members 
public: 

   virtual void draw(RenderTarget& target,  
      RenderStates states) const; 

   // More public members 

}; 

在前面的代码中,我们可以看到纯虚拟的draw函数和一个 Sprite。请注意,没有办法在类的外部访问私有的Sprite,甚至没有getSprite函数!

然后SpaceShip.cpp文件看起来会像下面这样:

void SpaceShip::SpaceShip 
{ 
   // Set up the spaceship 
} 

void SpaceShip::draw(RenderTarget& target, RenderStates states) const 
{ 
   target.draw(m_Sprite, states); 
} 

// Any other functions 

在前面的代码中,请注意draw函数的简单实现。参数超出了本书的范围。只需注意target参数用于调用draw并传入m_Sprite以及states,另一个参数。

提示

虽然不需要理解参数来充分利用Drawable,但在本书的上下文中,你可能会感兴趣。你可以在 SFML 网站上阅读更多关于 SFML Drawable类的信息:www.sfml-dev.org/tutorials/2.3/graphics-vertex-array.php#creating-an-sfml-like-entity

在主游戏循环中,我们现在可以将SpaceShip实例视为Sprite,或者从Drawable继承的任何其他类:

SpaceShip m_SpaceShip; 
// create other objects here 
// ... 

// In the draw function 
// Rub out the last frame 
m_Window.clear(Color::Black); 

// Draw the spaceship 
m_Window.draw(m_SpaceShip); 
// More drawing here 
// ... 

// Show everything we have just drawn 
m_Window.display(); 

正是因为SpaceShip是一个Drawable,我们才能将其视为SpriteVertexArray,并且因为我们覆盖了纯虚拟的draw函数,一切都按我们想要的方式运行。让我们看看另一种将绘图代码封装到游戏对象中的方法。

从 Drawable 继承的另一种选择

通过在我们的类内部实现自己的函数,也许像以下代码一样,也可以将所有绘图功能保留在要绘制的对象类中:

void drawThisObject(RenderWindow window) 
{ 
   window.draw(m_Sprite) 
} 

先前的代码假设m_Sprite代表了我们正在绘制的当前类的视觉外观,就像在这个和前一个项目中一直使用的那样。假设包含drawThisObject函数的类的实例被称为playerHero,并且进一步假设我们有一个名为m_WindowRenderWindow的实例,我们可以在主游戏循环中使用以下代码绘制对象:

 playerHero.draw(m_Window); 

在这个解决方案中,我们将RenderWindow m_Window作为参数传递给drawThisObject函数。然后drawThisObject函数使用RenderWindow来绘制Sprite m_Sprite

与扩展Drawable相比,这个解决方案似乎更简单。我们之所以按照建议的方式(扩展 Drawable)做事情的原因并不是因为这个项目本身有很大的好处。我们很快将用这种方法绘制一个整洁的爆炸,原因是这是一个很好的学习技巧。

为什么最好继承自 Drawable?

通过本书完成的每个项目,我们都学到了更多关于游戏、C++和 SFML。从一个游戏到下一个游戏,我们所做的最大的改进可能是我们的代码结构——我们所使用的编程模式

如果这本书有第四个项目,我们可以进一步发展。不幸的是,没有,但是想一想以下改进我们代码的想法。

想象我们游戏中的每个对象都是从一个简单的抽象基类派生出来的。让我们称之为GameObject。游戏对象可能有具体的getPosition等函数。它可能有一个纯虚拟的update函数(因为每个对象的更新方式都不同)。此外,考虑GameObject继承自Drawable

现在看看这个假设的代码:

vector<GameObject> m_GameObjects; 
// Code to initialise all game objects 
// Including tiles, characters, enemies, bullets and anything else 

// In the update function 
for (i = m_GameObjects.begin(); i != m_GameObjects.end(); i++) 
{ 
   (*i).update(elapsedTime); 
} 
// That's it! 

// In the draw function 
// Rub out the last frame 
m_Window.clear(Color::Black); 

for (i = m_GameObjects.begin(); i != m_GameObjects.end(); i++) 
{ 
   m_Window.draw(*i); 
} 

// Show everything we have just drawn 
m_Window.display(); 
// That's it! 

与甚至最终项目相比,上述代码在封装、代码可管理性和优雅方面有了很大的进步。如果你看一下以前的代码,你会注意到有一些未解答的问题,比如碰撞检测在哪里等等。然而,希望你能看到,进一步的学习(通过构建很多游戏)将是掌握 C++所必需的。

虽然我们不会以这种方式实现整个游戏,但我们将看到如何设计一个类(ParticleSystem)并将其直接传递给m_Window.draw(m_MyParticleSystemInstance)

构建一个粒子系统

在我们开始编码之前,看一看我们要实现的确切目标将会很有帮助。看一下以下截图:

构建一个粒子系统

这是一个纯背景上粒子效果的截图。我们将在我们的游戏中使用这个效果。

我们实现效果的方式如下:

  1. 在选择的像素位置生成 1,000 个点(粒子),一个叠在另一个上面。

  2. 在游戏的每一帧中,以预定但随机的速度和角度将 1,000 个粒子向外移动。

  3. 重复第二步两秒钟,然后使粒子消失。

我们将使用VertexArray来绘制所有的点,使用Point的原始类型来直观地表示每个粒子。此外,我们将继承自Drawable,以便我们的粒子系统可以自行绘制。

编写 Particle 类

Particle类将是一个简单的类,代表了 1,000 个粒子中的一个。让我们开始编码。

编写 Particle.h

解决方案资源管理器中右键单击头文件,然后选择添加 | 新建项...。在添加新项窗口中,通过左键单击头文件.h)突出显示,然后在名称字段中键入Particle.h。最后,单击添加按钮。我们现在准备为Particle类编写头文件。

将以下代码添加到Particle.h文件中:

#pragma once 
#include <SFML/Graphics.hpp> 

using namespace sf; 

class Particle 
{ 
private: 
   Vector2f m_Position; 
   Vector2f m_Velocity; 

public: 
   Particle(Vector2f direction); 

   void update(float dt); 

   void setPosition(Vector2f position); 

   Vector2f getPosition(); 
}; 

在上述代码中,我们有两个Vector2f对象。一个代表粒子的水平和垂直坐标,另一个代表水平和垂直速度。

注意

当你有一个以上方向的变化率(速度)时,合并的值也定义了一个方向。这就是所谓的速度;因此,Vector2f 被称为'm_Velocity'。

我们还有一些公共函数。首先是构造函数。它接受一个'Vector2f',将用于让它知道这个粒子将具有什么方向/速度。这意味着系统,而不是粒子本身,将选择速度。

接下来是'update'函数,它接受前一帧所花费的时间。我们将使用这个时间精确地移动粒子。

最后两个函数,'setPosition'和'getPosition',用于将粒子移动到位置并找出其位置。

当我们编写这些函数时,所有这些函数都会变得非常清晰。

编写 Particle.cpp 文件

在“解决方案资源管理器”中右键单击“源文件”,然后选择“添加”|“新项目...”。在“添加新项目”窗口中,突出显示(通过左键单击)“C++文件”(.cpp),然后在“名称”字段中键入Particle.cpp。最后,单击“添加”按钮。我们现在准备为 Particle 类编写.cpp 文件。

将以下代码添加到'Particle.cpp'中:

#include "stdafx.h" 
#include "Particle.h" 

Particle::Particle(Vector2f direction) 
{ 

   // Determine the direction 
   //m_Velocity = direction; 
   m_Velocity.x = direction.x; 
   m_Velocity.y = direction.y; 
} 

void Particle::update(float dtAsSeconds) 
{ 
   // Move the particle 
   m_Position += m_Velocity * dtAsSeconds; 
} 

void Particle::setPosition(Vector2f position) 
{ 
   m_Position = position; 

} 

Vector2f Particle::getPosition() 
{ 
   return m_Position; 
} 

所有这些函数都使用了我们之前见过的概念。构造函数使用传入的 Vector2f 对象设置了'm_Velocity.x'和'm_Velocity.y'的值。

'update'函数通过将经过的时间('dtAsSeconds')乘以'm_Velocity'来移动粒子的水平和垂直位置。请注意,为了实现这一点,我们只需将两个'Vector2f'对象相加即可。无需分别为'x'和'y'成员执行计算。

如前所述,'setPosition'函数使用传入的值初始化了'm_Position'对象。'getPosition'函数将'm_Position'返回给调用代码。

现在我们有一个完全功能的 Particle 类。接下来,我们将编写一个 ParticleSystem 类来生成和控制粒子。

编写 ParticleSystem 类

'ParticleSystem'类为我们的粒子效果做了大部分工作。我们将在'Engine'类中创建这个类的一个实例。

编写 ParticleSystem.h

在“解决方案资源管理器”中右键单击“头文件”,然后选择“添加”|“新项目...”。在“添加新项目”窗口中,突出显示(通过左键单击)“头文件”(.h),然后在“名称”字段中键入ParticleSystem.h。最后,单击“添加”按钮。我们现在准备为 ParticleSystem 类编写头文件。

将 ParticleSystem 类的代码添加到 ParticleSystem.h 中:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "Particle.h" 

using namespace sf; 
using namespace std; 

class ParticleSystem : public Drawable 
{ 
private: 

   vector<Particle> m_Particles; 
   VertexArray m_Vertices; 
   float m_Duration; 
   bool m_IsRunning = false; 

public: 

   virtual void draw(RenderTarget& target, RenderStates states) const; 

   void init(int count); 

   void emitParticles(Vector2f position); 

   void update(float elapsed); 

   bool running(); 

}; 

让我们一点点来看这个。首先,注意我们是从'Drawable'继承的。这将使我们能够将我们的'ParticleSystem'实例传递给'm_Window.draw',因为'ParticleSystem'是一个'Drawable'。

有一个名为'm_Particles'的类型为'Particle'的向量。这个向量将保存每个'Particle'的实例。接下来我们有一个名为'm_Vertices'的'VertexArray'。这将用于以一堆'Point'原语的形式绘制所有的粒子。

'm_Duration','float'变量是每个效果持续的时间。我们将在构造函数中初始化它。

布尔值'm_IsRunning'变量将用于指示粒子系统当前是否正在使用。

接下来,在公共部分,我们有一个纯虚函数'draw',我们将很快实现它来处理当我们将 ParticleSystem 的实例传递给'm_Window.draw'时发生的情况。

'init'函数将准备'VertexArray'和'vector'。它还将使用它们的速度和初始位置初始化所有的'Particle'对象(由'vector'持有)。

'update'函数将循环遍历'vector'中的每个'Particle'实例,并调用它们各自的'update'函数。

running函数提供对m_IsRunning变量的访问,以便游戏引擎可以查询ParticleSystem当前是否正在使用。

让我们编写函数定义,看看ParticleSystem内部发生了什么。

编写 ParticleSystem.cpp 文件

右键单击Solution Explorer中的Source Files,然后选择Add | New Item...。在Add New Item窗口中,通过左键单击C++ File ( .cpp )突出显示,然后在Name字段中键入ParticleSystem.cpp。最后,单击Add按钮。现在我们准备好为ParticleSystem类编写.cpp文件了。

我们将把这个文件分成五个部分来编码和讨论。按照这里所示,添加代码的第一部分:

#include "stdafx.h" 
#include <SFML/Graphics.hpp> 
#include "ParticleSystem.h" 

using namespace sf; 
using namespace std; 

void ParticleSystem::init(int numParticles) 
{ 
   m_Vertices.setPrimitiveType(Points); 
   m_Vertices.resize(numParticles); 

   // Create the particles 

   for (int i = 0; i < numParticles; i++) 
   { 
      srand(time(0) + i); 
      float angle = (rand() % 360) * 3.14f / 180.f; 
      float speed = (rand() % 600) + 600.f; 

      Vector2f direction; 

      direction = Vector2f(cos(angle) * speed, 
         sin(angle) * speed); 

      m_Particles.push_back(Particle(direction)); 

   } 

} 

在必要的includes之后,我们有init函数的定义。我们调用setPrimitiveType,参数为Points,以便m_VertexArray知道它将处理什么类型的基元。我们使用numParticles调整m_Vertices的大小,这是在调用init函数时传入的。

for循环创建速度和角度的随机值。然后使用三角函数将这些值转换为一个向量,存储在Vector2f direction中。

提示

如果您想了解三角函数(cossintan)如何将角度和速度转换为向量,可以查看这篇文章系列:gamecodeschool.com/essentials/calculating-heading-in-2d-games-using-trigonometric-functions-part-1/

for循环(和init函数)中发生的最后一件事是将向量传递给Particle构造函数。新的Particle实例使用push_back函数存储在m_Particles中。因此,使用值1000调用init将意味着我们有一千个Particle实例,具有随机速度,藏在m_Particles中等待爆炸!

接下来,在ParticleSysytem.cpp中添加update函数:

void ParticleSystem::update(float dt) 
{ 
   m_Duration -= dt; 
   vector<Particle>::iterator i; 
   int currentVertex = 0; 

   for (i = m_Particles.begin(); i != m_Particles.end(); i++) 
   { 
      // Move the particle 
      (*i).update(dt); 

      // Update the vertex array 
      m_Vertices[currentVertex].position = (*i).getPosition(); 

      // Move to the next vertex 
      currentVertex++; 
   } 

   if (m_Duration < 0) 
   { 
      m_IsRunning = false; 
   } 

} 

update函数比起初看起来要简单一些。首先,m_Duration会减去传入的时间dt,这样我们就知道两秒已经过去了。声明一个向量迭代器i,用于m_Particles

for循环遍历m_Particles中的每个Particle实例。对于每一个粒子,它都调用其update函数并传入dt。每个粒子都将更新其位置。粒子更新完毕后,通过使用粒子的getPosition函数更新m_Vertices中的适当顶点。在每次循环结束时,for循环中的currentVertex都会递增,准备好下一个顶点。

for循环完成后,if(m_Duration < 0)检查是否是时候关闭效果了。如果两秒已经过去,m_IsRunning将设置为false

接下来,添加emitParticles函数:

void ParticleSystem::emitParticles(Vector2f startPosition) 
{ 
   m_IsRunning = true; 
   m_Duration = 2; 

   vector<Particle>::iterator i; 
   int currentVertex = 0; 

   for (i = m_Particles.begin(); i != m_Particles.end(); i++) 
   { 
      m_Vertices[currentVertex].color = Color::Yellow; 
      (*i).setPosition(startPosition); 

      currentVertex++; 
   } 

} 

这是我们将调用来启动粒子系统运行的函数。因此,可以预料到,我们将m_IsRunning设置为truem_Duration设置为2。我们声明一个iterator i,用于遍历m_Particles中的所有Particle对象,然后在for循环中这样做。

for循环中,我们将顶点数组中的每个粒子设置为黄色,并将每个位置设置为传入的startPosition。请记住,每个粒子的生命都从完全相同的位置开始,但它们各自被分配了不同的速度。

接下来,添加纯虚拟的 draw 函数定义:

void ParticleSystem::draw(RenderTarget& target, RenderStates states) const 
{ 
   target.draw(m_Vertices, states); 
} 

在之前的代码中,我们简单地使用target调用draw,传入m_Verticesstates。这与我们在本章早些时候讨论Drawable时讨论的完全一样,只是我们传入了我们的VertexArray,其中包含了 1000 个点的基元,而不是假设的太空飞船 Sprite。

最后,添加running函数:

bool ParticleSystem::running() 
{ 
   return m_IsRunning; 
} 

running函数是一个简单的 getter 函数,返回m_IsRunning的值。我们将看到这在确定粒子系统的当前状态时是有用的。

使用 ParticleSystem

让我们的粒子系统工作非常简单,特别是因为我们继承自Drawable

向 Engine 类添加一个 ParticleSystem 对象

打开Engine.h并添加一个ParticleSystem对象,如下所示的高亮代码:

#pragma once 
#include <SFML/Graphics.hpp> 
#include "TextureHolder.h" 
#include "Thomas.h" 
#include "Bob.h" 
#include "LevelManager.h" 
#include "SoundManager.h" 
#include "HUD.h" 
#include "ParticleSystem.h" 

using namespace sf; 

class Engine 
{ 
private: 
   // The texture holder 
   TextureHolder th; 

 // create a particle system
   ParticleSystem m_PS; 

   // Thomas and his friend, Bob 
   Thomas m_Thomas; 
   Bob m_Bob; 

接下来,初始化系统。

初始化 ParticleSystem

打开Engine.cpp文件,并在Engine构造函数的最后添加短暗示代码:

Engine::Engine() 
{ 
   // Get the screen resolution and create an SFML window and View 
   Vector2f resolution; 
   resolution.x = VideoMode::getDesktopMode().width; 
   resolution.y = VideoMode::getDesktopMode().height; 

   m_Window.create(VideoMode(resolution.x, resolution.y), 
      "Thomas was late", 
      Style::Fullscreen); 

   // Initialize the full screen view 
   m_MainView.setSize(resolution); 
   m_HudView.reset( 
      FloatRect(0, 0, resolution.x, resolution.y)); 

   // Inititialize the split-screen Views 
   m_LeftView.setViewport( 
      FloatRect(0.001f, 0.001f, 0.498f, 0.998f)); 

   m_RightView.setViewport( 
      FloatRect(0.5f, 0.001f, 0.499f, 0.998f)); 

   m_BGLeftView.setViewport( 
      FloatRect(0.001f, 0.001f, 0.498f, 0.998f)); 

   m_BGRightView.setViewport( 
      FloatRect(0.5f, 0.001f, 0.499f, 0.998f)); 

   // Can this graphics card use shaders? 
   if (!sf::Shader::isAvailable()) 
   { 
      // Time to get a new PC 
      m_Window.close(); 
   } 

   m_BackgroundTexture = TextureHolder::GetTexture( 
      "graphics/background.png"); 

   // Associate the sprite with the texture 
   m_BackgroundSprite.setTexture(m_BackgroundTexture); 

   // Load the texture for the background vertex array 
   m_TextureTiles = TextureHolder::GetTexture( 
      "graphics/tiles_sheet.png"); 

 // Initialize the particle system
   m_PS.init(1000); 

}// End Engine constructor 

VertexArrayParticle实例的vector已经准备就绪。

在每一帧更新粒子系统

打开Update.cpp文件,并在update函数的最后添加以下高亮代码:

   // Update the HUD every m_TargetFramesPerHUDUpdate frames 
   if (m_FramesSinceLastHUDUpdate > m_TargetFramesPerHUDUpdate) 
   { 
      // Update game HUD text 
      stringstream ssTime; 
      stringstream ssLevel; 

      // Update the time text 
      ssTime << (int)m_TimeRemaining; 
      m_Hud.setTime(ssTime.str()); 

      // Update the level text 
      ssLevel << "Level:" << m_LM.getCurrentLevel(); 
      m_Hud.setLevel(ssLevel.str()); 

      m_FramesSinceLastHUDUpdate = 0; 
   } 

 // Update the particles
   if (m_PS.running())
   {
     m_PS.update(dtAsSeconds);
   } 

}// End of update function 

在上一个代码中,只需要调用update。请注意,它被包裹在一个检查中,以确保系统当前正在运行。如果它没有运行,更新它就没有意义。

启动粒子系统

打开DetectCollisions.cpp文件,其中包含detectCollisions函数。当我们最初编写它时,我们在第十五章中留下了一个注释,构建可玩级别和碰撞检测

从上下文中找到正确的位置并添加高亮代码,如下所示:

// Is character colliding with a regular block 
if (m_ArrayLevel[y][x] == 1) 
{ 

   if (character.getRight().intersects(block)) 
   { 
      character.stopRight(block.left); 
   } 
   else if (character.getLeft().intersects(block)) 
   { 
      character.stopLeft(block.left); 
   } 

   if (character.getFeet().intersects(block)) 
   { 
      character.stopFalling(block.top); 
   } 
   else if (character.getHead().intersects(block)) 
   { 
      character.stopJump(); 
   } 
} 

// More collision detection here once  
// we have learned about particle effects 

// Has the character's feet touched fire or water?
// If so, start a particle effect
// Make sure this is the first time we have detected this
// by seeing if an effect is already running
if (!m_PS.running())
{
   if (m_ArrayLevel[y][x] == 2 || m_ArrayLevel[y][x] == 3)
   {
     if (character.getFeet().intersects(block))
     {
        // position and start the particle system
        m_PS.emitParticles(character.getCenter());
     }
   }
} 

// Has the character reached the goal? 
if (m_ArrayLevel[y][x] == 4) 
{ 
   // Character has reached the goal 
   reachedGoal = true; 
}  

首先,代码检查粒子系统是否已经运行。如果没有运行,它会检查当前正在检查的瓦片是否是水瓦片或火瓦片。如果是这种情况,它会检查角色的脚是否接触。当这些if语句中的每一个为true时,通过调用emitParticles函数并传入角色中心的位置作为开始效果的坐标来启动粒子系统。

绘制粒子系统

这是最好的部分。看看绘制ParticleSystem有多容易。在检查粒子系统实际运行后,我们直接将实例传递给m_Window.draw函数。

打开Draw.cpp文件,并在以下代码中显示的所有位置添加高亮代码:

void Engine::draw() 
{ 
   // Rub out the last frame 
   m_Window.clear(Color::White); 

   if (!m_SplitScreen) 
   { 
      // Switch to background view 
      m_Window.setView(m_BGMainView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_MainView 
      m_Window.setView(m_MainView);     

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 

 // Draw the particle system
      if (m_PS.running())
      {
         m_Window.draw(m_PS);
      } 
   } 
   else 
   { 
      // Split-screen view is active 

      // First draw Thomas' side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGLeftView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_LeftView 
      m_Window.setView(m_LeftView); 

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

 // Draw the particle system
      if (m_PS.running())
      {
         m_Window.draw(m_PS);
      } 

      // Now draw Bob's side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGRightView); 
      // Draw the background 
      m_Window.draw(m_BackgroundSprite); 
      // Switch to m_RightView 
      m_Window.setView(m_RightView); 

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw bob 
      m_Window.draw(m_Bob.getSprite()); 

 // Draw the particle system
      if (m_PS.running())
      {
         m_Window.draw(m_PS);
      }           
   } 

   // Draw the HUD 
   // Switch to m_HudView 
   m_Window.setView(m_HudView); 
   m_Window.draw(m_Hud.getLevel()); 
   m_Window.draw(m_Hud.getTime()); 
   if (!m_Playing) 
   { 
      m_Window.draw(m_Hud.getMessage()); 
   } 

   // Show everything we have just drawn 
   m_Window.display(); 
} 

请注意,在上一个代码中,我们必须在所有的左侧、右侧和全屏代码块中绘制粒子系统。

运行游戏,并将角色的一只脚移动到火瓦片的边缘。注意粒子系统会突然活跃起来:

绘制粒子系统

现在是另一件新事物。

OpenGL、着色器和 GLSL

OpenGLOpen Graphics Library)是一个处理 2D 和 3D 图形的编程库。OpenGL 适用于所有主要桌面操作系统,还有一个适用于移动设备的版本 OpenGL ES。

OpenGL 最初是在 1992 年发布的。它在二十多年的时间里得到了改进和提高。此外,图形卡制造商设计他们的硬件使其与 OpenGL 良好地配合工作。告诉你这一点的目的不是为了历史课,而是为了解释如果你想让游戏在桌面上的 2D(和 3D)上运行,并且希望你的游戏不仅仅在 Windows 上运行,那么使用 OpenGL 是一个明显的选择。我们已经在使用 OpenGL,因为 SFML 使用 OpenGL。着色器是在 GPU 上运行的程序,所以让我们更多地了解它们。

可编程管线和着色器

通过 OpenGL,我们可以访问所谓的可编程管线。我们可以发送我们的图形进行绘制,每一帧都可以使用RenderWindowdraw函数。我们还可以编写在调用draw后在 GPU 上运行的代码,它能够独立地操纵每一个像素。这是一个非常强大的功能。

在 GPU 上运行的这些额外代码称为着色器程序。我们可以编写代码来操纵我们图形的几何(位置),这称为顶点着色器。我们还可以编写代码,以在称为片段着色器的代码中单独操纵每个像素的外观。

尽管我们不会深入探讨着色器,但我们将使用 GLSL 编写一些着色器代码,并且将一窥所提供的可能性。

在 OpenGL 中,一切都是点、线或三角形。此外,我们可以将颜色和纹理附加到这些基本几何图形,并且我们还可以将这些元素组合在一起,以制作我们在今天的现代游戏中看到的复杂图形。这些统称为基元。我们可以通过 SFML 基元和VertexArray以及我们所见过的SpriteShape类来访问 OpenGL 基元。

除了基元,OpenGL 还使用矩阵。矩阵是一种执行算术的方法和结构。这种算术可以从非常简单的高中水平计算移动(平移)坐标,或者可以非常复杂,执行更高级的数学;例如,将我们的游戏世界坐标转换为 OpenGL 屏幕坐标,GPU 可以使用。幸运的是,正是这种复杂性在幕后由 SFML 为我们处理。

SFML 还允许我们直接处理 OpenGL。如果您想了解更多关于 OpenGL 的信息,可以从这里开始:learnopengl.com/#!Introduction。如果您想直接在 SFML 旁边使用 OpenGL,可以阅读以下文章:www.sfml-dev.org/tutorials/2.3/window-opengl.php

一个应用程序可以有许多着色器。然后我们可以附加不同的着色器到不同的游戏对象上,以创建所需的效果。在这个游戏中,我们只会有一个顶点着色器和一个片段着色器。我们将在每一帧将其应用到背景上。

但是,当您了解如何将着色器附加到draw调用时,就会明显地知道可以轻松添加更多着色器。

我们将按照以下步骤进行:

  1. 首先,我们需要在 GPU 上执行的着色器代码。

  2. 然后我们需要编译该代码。

  3. 最后,我们需要将着色器附加到游戏引擎的绘图函数中的适当绘图调用。

GLSL 是一种独立的语言,它也有自己的类型,可以声明和使用这些类型的变量。此外,我们可以从我们的 C++代码与着色器程序的变量进行交互。

提示

如果对可编程图形管线和着色器的强大功能有进一步了解的话,那么我强烈推荐 Jacobo Rodríguez 的《GLSL Essentials》:www.packtpub.com/hardware-and-creative/glsl-essentials。该书探讨了桌面上的 OpenGL 着色器,并且对于具有良好的 C++编程知识并且愿意学习不同语言的任何读者来说都非常易于理解。

正如我们将看到的,GLSL 与 C++有一些语法相似之处。

编写片段着色器

这是shaders文件夹中rippleShader.frag文件中的代码。您不需要编写此代码,因为它在我们在第十二章中添加的资产中已经存在,抽象和代码管理-更好地利用 OOP

// attributes from vertShader.vert 
varying vec4 vColor; 
varying vec2 vTexCoord; 

// uniforms 
uniform sampler2D uTexture; 
uniform float uTime; 

void main() { 
   float coef = sin(gl_FragCoord.y * 0.1 + 1 * uTime); 
   vTexCoord.y +=  coef * 0.03; 
   gl_FragColor = vColor * texture2D(uTexture, vTexCoord); 
} 

前四行(不包括注释)是片段着色器将使用的变量。但它们不是普通的变量。我们看到的第一种类型是varying。这些是在两个shaders之间范围内的变量。接下来,我们有uniform变量。这些变量可以直接从我们的 C++代码中操作。我们很快将看到我们如何做到这一点。

除了varyinguniform类型之外,每个变量还有一个更常规的类型,用于定义实际数据:

  • vec4是一个具有四个值的向量

  • vec2是一个具有两个值的向量

  • sampler2d将保存一个纹理

  • float就像 C++中的float

main函数内部的代码才是实际执行的。如果你仔细看main中的代码,你会看到每个变量的使用。这段代码实际上做了什么超出了本书的范围。然而,总的来说,纹理坐标(vTexCoord)和像素/片段的颜色(glFragColor)被一系列数学函数和操作所操纵。请记住,这对我们游戏中每一帧的每一个像素都会执行,而且要知道uTime每一帧都会传入不同的值。结果,正如我们很快会看到的,将会产生一种波纹效果。

编写顶点着色器

这是vertShader.vert文件中的代码。你不需要编写这个,因为它在我们在第十二章中添加的资产中已经有了,抽象和代码管理-更好地利用 OOP

//varying "out" variables to be used in the fragment shader 
varying vec4 vColor; 
varying vec2 vTexCoord; 

void main() { 
    vColor = gl_Color; 
    vTexCoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy; 
    gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex; 
} 

首先,注意两个varying变量。这些正是我们在片段着色器中操作的变量。在main函数中,代码操纵了每个顶点的位置。代码的工作原理超出了本书的范围,但在幕后进行了一些相当深入的数学运算,如果你对此感兴趣,那么探索 GLSL 将会是很有趣的(参见前面的提示)。

现在我们有了两个着色器(一个片段着色器和一个顶点着色器)。我们可以在我们的游戏中使用它们。

将着色器添加到 Engine 类

打开Engine.h文件。添加突出显示的代码行,将一个名为m_RippleShader的 SFML Shader实例添加到Engine类中:

// Three views for the background 
View m_BGMainView; 
View m_BGLeftView; 
View m_BGRightView; 

View m_HudView; 

// Declare a sprite and a Texture for the background 
Sprite m_BackgroundSprite; 
Texture m_BackgroundTexture; 

// Declare a shader for the background
Shader m_RippleShader; 

// Is the game currently playing? 
bool m_Playing = false; 

// Is character 1 or 2 the current focus? 
bool m_Character1 = true; 

引擎对象及其所有函数现在都可以访问m_RippleShadder。请注意,一个 SFML Shader对象将由两个着色器代码文件组成。

加载着色器

添加以下代码,检查玩家的 GPU 是否能够处理着色器。如果不能,游戏将退出。

提示

你的电脑必须非常老旧才不能运行。如果你的 GPU 无法处理着色器,请接受我的道歉。

接下来,我们将添加一个 else 子句,如果系统能够处理它们,它将实际加载着色器。打开Engine.cpp文件,并将以下代码添加到构造函数中:

// Can this graphics card use shaders?
if (!sf::Shader::isAvailable())
{
   // Time to get a new PC
   m_Window.close();
}
else
{
   // Load two shaders (1 vertex, 1 fragment)
   m_RippleShader.loadFromFile("shaders/vertShader.vert",
     "shaders/rippleShader.frag");} 

m_BackgroundTexture = TextureHolder::GetTexture( 
   "graphics/background.png"); 

现在我们几乎准备好看到我们的波纹效果了。

在每一帧更新和绘制着色器

打开Draw.cpp文件。正如我们在编写着色器时讨论的那样,我们将在每一帧直接从我们的 C++代码中更新uTime变量。我们使用Uniform函数来实现这一点。

添加突出显示的代码以更新着色器的uTime变量,并在每种可能的绘制场景中为m_BackgroundSpritedraw调用更改:

void Engine::draw() 
{ 
   // Rub out the last frame 
   m_Window.clear(Color::White); 

 // Update the shader parameters
   m_RippleShader.setUniform("uTime", m_GameTimeTotal.asSeconds()); 

   if (!m_SplitScreen) 
   { 
      // Switch to background view 
      m_Window.setView(m_BGMainView); 
      // Draw the background 
 //m_Window.draw(m_BackgroundSprite);

     // Draw the background, complete with shader effect
     m_Window.draw(m_BackgroundSprite, &m_RippleShader); 

      // Switch to m_MainView 
      m_Window.setView(m_MainView);     

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 

      // Draw the particle system 
      if (m_PS.running()) 
      { 
         m_Window.draw(m_PS); 
      } 
   } 
   else 
   { 
      // Split-screen view is active 

      // First draw Thomas' side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGLeftView); 
      // Draw the background 
 //m_Window.draw(m_BackgroundSprite);

      // Draw the background, complete with shader effect
      m_Window.draw(m_BackgroundSprite, &m_RippleShader); 

      // Switch to m_LeftView 
      m_Window.setView(m_LeftView); 

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Bob.getSprite()); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw the particle system 
      if (m_PS.running()) 
      { 
         m_Window.draw(m_PS); 
      } 

      // Now draw Bob's side of the screen 

      // Switch to background view 
      m_Window.setView(m_BGRightView); 
      // Draw the background 
 //m_Window.draw(m_BackgroundSprite);
      // Draw the background, complete with shader effect
      m_Window.draw(m_BackgroundSprite, &m_RippleShader); 

      // Switch to m_RightView 
      m_Window.setView(m_RightView); 

      // Draw the Level 
      m_Window.draw(m_VALevel, &m_TextureTiles); 

      // Draw thomas 
      m_Window.draw(m_Thomas.getSprite()); 

      // Draw bob 
      m_Window.draw(m_Bob.getSprite()); 

      // Draw the particle system 
      if (m_PS.running()) 
      { 
         m_Window.draw(m_PS); 
      } 

   } 

   // Draw the HUD 
   // Switch to m_HudView 
   m_Window.setView(m_HudView); 
   m_Window.draw(m_Hud.getLevel()); 
   m_Window.draw(m_Hud.getTime()); 
   if (!m_Playing) 
   { 
      m_Window.draw(m_Hud.getMessage()); 
   } 

   // Show everything we have just drawn 
   m_Window.display(); 
} 

最好实际上删除我所展示的注释掉的代码行。我只是这样做是为了清楚地表明哪些代码行正在被替换。

运行游戏,你会得到一种怪异的熔岩效果。如果你想玩得开心,可以尝试更改背景图像:

在每一帧更新和绘制着色器

就是这样!我们的第三个也是最后一个游戏完成了。

摘要

在大结局中,我们探讨了粒子系统和着色器的概念。虽然我们可能只看到了每种情况的最简单的情况,但我们仍然成功地创建了一个简单的爆炸和一种怪异的熔岩效果。

请看一下最后的简短章节,讨论接下来要做什么。

第十七章:在你离开之前...

当你第一次翻开这本厚重的书时,最后一页可能看起来很遥远。但我希望这并不太困难!

重点是,你现在在这里,希望你已经对如何在 C++中构建游戏有了很好的见解。

本章的重点不仅是祝贺你取得了很好的成就,还要指出这一页可能不应该是你旅程的终点。如果像我一样,每当你让一个新的游戏特性变得生动起来时,你可能还想学习更多。

也许让你惊讶的是,即使在这数百页之后,我们只是浅尝 C++的皮毛。即使我们涉及的主题可能需要更深入的探讨,还有许多重要的主题我们甚至没有提到。考虑到这一点,让我们看看接下来可能会发生什么。

如果你绝对必须获得正式资格,那么唯一的方法就是接受正规教育。当然,这是昂贵和耗时的,我无法提供更多帮助。

另一方面,如果你想在工作中学习,也许是在开始制作最终发布的游戏时,接下来将讨论你可能想要做的事情。

可能我们每个项目面临的最艰难的决定之一就是如何构建我们的代码。在我看来,关于如何构建你的 C++游戏代码的绝佳信息来源是gameprogrammingpatterns.com/。其中一些讨论涉及到本书未涉及的概念,但其中很多内容都是完全可以理解的。如果你理解类、封装、纯虚函数和单例模式,那就深入了解这个网站吧。

我已经在整本书中多次提到了 SFML 的网站。如果你还没有访问过,请看一下这个链接:www.sfml-dev.org/

当你遇到你不理解的 C主题(甚至从未听说过的),最简洁和最有组织的 C教程可以在这个链接找到:www.cplusplus.com/doc/tutorial/

除此之外,还有四本关于 SFML 的书籍,你可能也想了解一下。它们都是很好的书,但适合的读者有很大差异。以下是这些书籍的列表,按照从最适合初学者到最技术性的顺序排列:

谢谢!

最重要的是,非常感谢购买这本书,并继续制作游戏!

posted @ 2024-05-04 22:44  绝不原创的飞龙  阅读(10)  评论(0编辑  收藏  举报