C++ 编程入门指南(一)
原文:
annas-archive.org/md5/024671a6ef06ea57693023eca62b8eea
译者:飞龙
前言
C++已经使用了 30 年,在这段时间里,许多新的语言出现并消失,但 C++却一直存在。这本书背后的重要问题是:为什么?为什么要使用 C++?答案在你面前看到的十章中,但作为一个剧透,它是语言的灵活性和强大性以及丰富广泛的标准库。
C++一直是一种强大的语言,可以直接访问内存,同时提供高级功能,如能够创建新类型(类)和根据需要重写运算符。然而,更现代的 C++标准增加了模板的通用编程和函数对象和 lambda 表达式的函数式编程。您可以根据需要使用这些功能的多少;您可以使用抽象接口指针编写事件驱动的代码,也可以使用类似 C 的过程式代码。
在本书中,我们将介绍 C++ 2011 标准和语言提供的标准库的特性。本文解释了如何使用这些特性与简短的代码片段,并且每一章都有一个示例来说明概念。在本书结束时,您将了解语言的所有特性以及 C++标准库的可能性。您将作为一个初学者开始本书,并在结束时具备使用 C++的知识和能力。
本书内容
第一章,从 C++开始,解释了用于编写 C++应用程序的文件,文件依赖关系以及 C++项目管理的基础知识。
第二章,理解语言特性,涵盖了 C++语句和表达式、常量、变量、运算符以及如何控制应用程序中的执行流程。
第三章,探索 C++类型,描述了 C++内置类型、聚合类型、类型别名、初始化列表以及类型之间的转换。
第四章,使用内存、数组和指针,涵盖了 C++应用程序中内存的分配和使用方式,如何使用内置数组,C++引用的作用以及如何使用 C++指针来访问内存。
第五章,使用函数,解释了如何定义函数,如何通过引用和按值传递参数使用可变数量的参数,创建和使用函数指针,以及定义模板函数和重载运算符。
第六章,类,描述了如何通过类定义新类型以及类中使用的各种特殊函数,如何将类实例化为对象以及如何销毁它们,以及如何通过指针访问对象以及如何编写模板类。
第七章,面向对象编程简介,解释了继承和组合,以及这如何影响使用指针和引用对象以及类成员的访问级别以及它们如何影响继承的成员。本章还通过虚方法解释了多态性,并通过抽象类解释了继承编程。
第八章,使用标准库容器,涵盖了所有 C++标准库容器类以及如何使用它们与迭代器和标准算法,以便操作容器中的数据。
第九章,使用字符串,描述了标准 C++字符串类的特性,将数值数据和字符串之间的转换,国际化字符串以及使用正则表达式搜索和操作字符串。
第十章,诊断和调试,解释了如何准备代码以提供诊断并使其能够进行调试,应用程序如何被终止,突然或优雅地,以及如何使用 C++异常。
本书所需的内容
本书涵盖了 C++11 标准以及相关的 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++提供了符合 C++11 标准的语言特性,几乎所有 C++14 和 C++17 的语言特性。Visual C++还提供了 C99 运行时库、C++11 标准库和 C++14 标准库。所有这些标准的提及意味着您在本书中学习到的代码将与所有其他标准的 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 安装了两个:MSBuild和nmake。在 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++源文件,通常扩展名为cpp
或cxx
。
一个简单的例子
这里展示了最简单的 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)。
有几件事情需要注意。首先,文件夹bin
、include
和lib
:
文件夹 | 描述 |
---|---|
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 文件夹的路径,并设置INCLUDE
和LIB
环境变量分别指向 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 C++的include
文件夹中找到此文件。在文件顶部,您将找到以下内容:
// 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.cpp
,file1.cpp
和file2.cpp
)。每个文件都包含相同的头文件utils.h
,这是预编译的(因此有第四个源文件utils.cpp
,只包含utils.h
)。所有源文件都依赖于utils.pch
,而utils.pch
又依赖于utils.h
。源文件main.cpp
有main
函数,并调用其他两个源文件(file1.cpp
和file2.cpp
)中的函数,并通过相关的头文件file1.h
和file2.h
访问这些函数。
在第一次编译时,构建工具将看到可执行文件依赖于四个对象文件,因此它将寻找构建每个对象文件的规则。对于三个 C++源文件,这意味着编译cpp
文件,但由于utils.obj
用于支持预编译头,构建规则将与其他文件不同。当构建工具制作了这些对象文件后,它将把它们与任何库代码一起链接在一起(这里没有显示)。
随后,如果你改变file2.cpp
并构建项目,构建工具将看到只有file2.cpp
已经改变,而只有file2.obj
依赖于file2.cpp
,那么 make 工具需要做的就是编译file2.cpp
,然后将新的file2.obj
与现有的对象文件链接在一起创建可执行文件。如果你改变头文件file2.h
,构建工具将看到两个文件依赖于这个头文件,file2.cpp
和main.cpp
,因此构建工具将编译这两个源文件,并将新的两个对象文件file2.obj
和main.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.h
和File2.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_name
和print_time
。这些函数在三个单独的源文件中,由于main
函数将调用其他两个源文件中的函数,这意味着main
源文件将需要这些函数的原型。在这个例子中,这意味着每个文件都需要一个头文件。该项目还将使用预编译头文件,这意味着一个源文件和一个头文件。总共,这意味着将使用三个头文件和四个源文件。
创建预编译头文件
该代码将使用 C++标准库通过流进行输入和输出,因此将使用<iostream>
头文件。该代码将使用 C++的string
类型来处理输入,因此将使用<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.pch
和utils.obj
依赖于指定的源文件和头文件。第二行(以制表符为前缀)告诉编译器编译 C++文件,而不是调用链接器,并告诉编译器将预编译代码保存到utils.h
中。该命令将创建utils.pch
和utils.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.pch
和utils.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_name
和print_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::time
和std::gmtime
是 C 函数,std::time_t
是 C 类型;所有这些都可以通过 C++标准库获得。std::time
函数获取自 1970 年 1 月 1 日午夜以来的秒数作为时间。该函数返回std::time_t
类型的值,这是一个 64 位整数。如果您传递一个指向变量存储位置的指针,该函数可以选择将此值复制到另一个变量中。在这个例子中,我们不需要这个功能,所以我们将 C++的nullptr
传递给函数,表示不应执行复制。
接下来,我们需要将秒数转换为您可以理解的时间和日期格式的字符串。这就是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.cpp
和main.cpp
,它们与现有的对象文件链接以创建可执行文件。要了解为什么这两个文件被编译,请查看 makefile 中的依赖规则。唯一更改的文件是name.h
,并且该文件在name.obj
和main.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&R和Allman。
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
到右侧的分号)是一个语句。
每个表达式都是lvalue或rvalue。你最有可能在错误描述中看到这些关键字。实际上,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 + 8
和b + 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++还保留了关键字(例如while
和if
),显然您不能使用类型名称作为变量名称,无论是内置类型名称(int
、long
等)还是您自己的自定义类型。
您在语句中声明变量,并以分号结束。声明变量的基本语法是指定类型,然后是名称,以及可选的变量初始化。
内置类型必须在使用之前初始化:
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
,逻辑类型,有true
和false
常量,其中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_data
或utilities::V1::get_data
。
完全限定的名称可能会使代码难以阅读,特别是如果您的代码只使用一个命名空间。在这里,您有几个选项可以帮助。您可以放置一个using
语句来指示可以在指定的命名空间中声明的符号可以在不使用完全限定名称的情况下使用:
using namespace utilities;
int i = get_data();
int j = V2::get_data();
您仍然可以使用完全限定的名称,但此语句允许您放宽要求。请注意,嵌套命名空间是命名空间的成员,因此前面的using
语句意味着您可以使用utilities::V2::get_data
或V2::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
前缀的标准库头文件(例如cmath
、cstdlib
和ctime
)通过在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.cpp
和print.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_name
的std::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: 从左到右的结合性 | | |
13: 从左到右的结合性 | && |
14: 从左到右的结合性 | || |
15: 从右到左的结合性 | ? : |
16: 从右到左的结合性 | = *= /= %= += -= <<= >>= &= |= ^= |
17: 从右到左的结合性 | throw |
18: 从左到右的结合性 | , |
例如,看下面的代码:
int a = b + c * d;
这被解释为首先执行乘法,然后执行加法。写相同代码的更清晰的方法是:
int a = b + (c * d);
原因是 *
的优先级高于 +
,因此首先执行乘法,然后执行加法:
int a = b + c + d;
在这种情况下,+
运算符具有相同的优先级,高于赋值的优先级。由于 +
具有从左到右的结合性,该语句的解释如下:
int a = ((b + c) + d);
也就是说,首先执行 b
和 c
的加法,然后将结果加到 d
上,然后将这个结果用于赋值给 a
。这可能看起来不重要,但请记住,加法可能是在函数调用之间进行的(函数调用的优先级高于 +
):
int a = b() + c() + d();
这意味着这三个函数按照从左到右的结合性的顺序被调用,即 b
,c
,d
,然后它们的返回值被相加。这可能很重要,因为 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
的值。
递增和递减运算符可以应用于整数和浮点数。这些运算符也可以应用于指针,其中它们具有特殊含义。当你递增一个指针变量时,它的意思是递增指针的大小。
位运算符
整数可以被视为一系列位,0
或1
。位运算符作用于这些位,与另一个操作数中相同位置的位进行比较。有符号整数使用一位来表示符号,但位运算符作用于整数的每一位,因此通常只有在无符号整数上使用它们才有意义。在以下内容中,所有类型都标记为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";
此代码涉及三个测试;第一个测试x
和y
变量是否具有相同的值,第二个测试变量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
,则执行:
右侧的表达式。运算符执行的表达式提供了条件运算符的返回值。
例如,以下代码确定了两个变量a
和b
的最大值:
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 将同时打印one
和less than three
到控制台,因为执行穿透到前面的case
,即使该case
是另一个值。
通常每个case
都有不同的代码,因此您最常会在case
结束时使用break
。如果不小心忽略了break
,这将导致异常行为。在有意省略break
语句时,最好记录您的代码,以便知道如果缺少break
,那很可能是一个错误。
您可以为每个case
提供零个或多个语句。如果有多个语句,则它们都将执行该特定case
。如果您不提供语句(例如在此示例中的case 4
),那么这意味着不会执行任何语句,甚至不会执行default
子句中的语句。
break
语句表示跳出此代码块,并且在while
和for
循环语句中也是如此。还有其他方法可以跳出switch
。case
可以调用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
循环,并且还可以使用return
,goto
或throw
。您很少会看到使用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
循环中的最后一行不会被执行。原因是最后一行打印了倒数,将任何数字除以零都会出错。
C++11 引入了另一种使用for
循环的方法,这种方法旨在与容器一起使用。C++标准库包含容器类的模板。这些类包含对象的集合,并以标准方式提供对这些项目的访问。标准方式是使用迭代器对象遍历集合。如何做到这一点的更多细节将在第八章中给出,使用标准库容器;这种语法需要理解指针和迭代器,所以我们在这里不会涉及它们。基于范围的for
循环提供了一种简单的机制来访问容器中的项目,而不需要显式使用迭代器。
语法很简单:
for (for_declaration : expression) loop_statement;
首先要指出的是只有两个表达式,它们之间用冒号(:
)分隔。第一个表达式用于声明循环变量,它是正在迭代的集合中项目的类型。第二个表达式提供对集合的访问。
在 C++术语中,可以使用的集合是那些定义了begin
和end
函数以访问迭代器的集合,以及基于堆栈的数组(编译器知道大小)。
标准库定义了一个叫做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
语句,这样vector
和string
类就不必使用完全限定的名称。
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
循环将遍历容器中的所有项目,但与之前的版本一样,你可以使用break
、return
、throw
或goto
离开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
一样,您可以使用break
、return
、throw
或goto
退出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::cout
和std::string
;因此,在文件顶部添加定义这些的头文件,并且为了不必使用完全限定的名称,添加一个using
语句:
#include <iostream>
#include <string>
using namespace std;
您将通过命令行传递表达式,因此在文件底部添加一个接受命令行参数的main
函数:
int main(int argc, char *argv[])
{
}
应用程序处理形式为arg1 op arg2
的表达式,其中op
是运算符,arg1
和arg2
是参数。这意味着,当调用应用程序时,必须有四个参数;第一个是用于启动应用程序的命令,最后三个是表达式。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
,它将是不正确的,但在42
和47
之间还有两个我们想要拒绝的字符:,
(44
)和.
(46
)。这就是为什么我们有前面的条件:“如果字符小于 42 或大于47
,或者是44
或46
,那么拒绝它。”
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++类型以及如何将一个类型的值转换为另一个类型。