C++ 现代编程(一)
原文:
annas-archive.org/md5/F02528C543403FA60BC7527E0C58459D译者:飞龙
前言
C++是最广泛使用的编程语言之一。它快速,灵活,高效,用于解决许多编程问题。
这个学习路径的目标是使您熟悉并熟悉 C++。通过学习语言结构,函数和类,您将熟悉 C++编程的构造,这将帮助您识别代码中的执行流程。您将探索并了解 C++标准库的重要性,以及为编写更好,更快的程序而进行的内存分配。
这个学习路径还涉及理解高级 C++编程所面临的挑战。您将学习高级主题,如多线程,网络,并发性,性能,元编程,lambda 表达式,正则表达式,测试等,以食谱的形式。
通过这个学习路径的结束,您将成为 C++的专家。
这本书适合谁
这个学习路径是为想要在 C++中建立坚实基础的开发人员设计的。一台计算机,一个互联网连接,以及学习如何在 C++中编码的愿望就是您开始这个学习路径所需要的一切。
这本书涵盖了什么
第一章《理解语言特性》涵盖了 C++语句和表达式,常量,变量,运算符,以及如何控制应用程序中的执行流程。
第二章《使用内存,数组和指针》涵盖了在 C++应用程序中如何分配和使用内存,如何使用内置数组,C++引用的作用,以及如何使用 C++指针来访问内存。
第三章《使用函数》解释了如何定义函数,如何通过引用和按值传递参数,使用可变数量的参数,创建和使用函数指针,以及定义模板函数和重载运算符。
第四章《类》描述了如何通过类定义新类型以及类中使用的各种特殊函数,如何将类实例化为对象以及如何销毁它们,以及如何通过指针访问对象以及如何编写模板类。
第五章《使用标准库容器》涵盖了所有 C++标准库容器类以及如何使用它们与迭代器和标准算法,以便您可以操作容器中的数据。
第六章《使用字符串》描述了标准 C++字符串类的特性,数字数据和字符串之间的转换,国际化字符串,以及使用正则表达式来搜索和操作字符串。
第七章《诊断和调试》解释了如何准备您的代码以提供诊断,并使其能够进行调试,应用程序是如何终止的,是突然还是优雅地,以及如何使用 C++异常。
第八章《学习现代核心语言特性》教授了现代核心语言特性,包括类型推断,统一初始化,作用域枚举,基于范围的 for 循环,结构化绑定等。
第九章《使用数字和字符串》讨论了如何在数字和字符串之间进行转换,生成伪随机数,使用正则表达式以及各种类型的字符串。
第十章《探索函数》深入探讨了默认和删除的函数,可变模板,lambda 表达式和高阶函数。
第十一章,标准库容器、算法和迭代器,向您介绍了几种标准容器,许多算法,并教您如何编写自己的随机访问迭代器。
第十二章,数学问题,包含一系列数学练习,为您做好准备,以应对接下来章节中更具挑战性的问题。
第十三章,语言特性,提出了一些问题供您练习运算符重载,移动语义,用户定义的文字,以及模板元编程方面的问题,如可变函数,折叠表达式和类型特征。
第十四章,字符串和正则表达式,存在一些字符串操作问题,例如在字符串和其他数据类型之间转换,拆分和连接字符串,以及处理正则表达式。
第十五章,流和文件系统,涵盖了输出流操作和使用 C++17 filesystem库处理文件和目录。
第十六章,日期和时间,为即将到来的 C++20 对chrono库的扩展做准备,其中包含了几个日历和时区问题,您可以使用date库解决这些问题,新的标准扩展就是基于这个库的。
第十七章,算法和数据结构,是最大的章节之一,包含各种问题,您需要利用现有的标准算法;另一些问题是您需要实现自己的通用算法或数据结构,比如循环缓冲区和优先队列。本章以两个相当有趣的问题结束,分别是道金斯的鼠鼠程序和康威的生命游戏程序,您可以从中了解到进化算法和细胞自动机。
为了充分利用本书
读者应该具备以下环境配置:
-
C++11(英特尔、IBM、Sun、苹果和微软,以及开源 GCC)
-
Visual C++ 2017 社区版
-
Windows 上的 VC++ 2017
-
在 Linux 和 Mac 上使用 GCC 7.0 或 Clang 5.0
如果您没有最新版本的编译器,或者想尝试另一个编译器,您可以使用在线可用的编译器。虽然有各种在线平台可供您使用,但我推荐使用wandbox.org/来使用 GCC 和 Clang,以及webcompiler.cloudapp.net/来使用 VC++。
在使用支持 C++17 的编译器时,您将需要一个所需库的完整列表。
如何为 Visual Studio 2017 生成项目
为了生成 Visual Studio 2017 项目以定位 x86 平台,请按照以下步骤进行:
-
打开命令提示符并转到源代码根文件夹中的
build目录。 -
执行以下 CMake 命令:
cmake -G "Visual Studio 15 2017" .. -DCMAKE_USE_WINSSL=ON -DCURL_WINDOWS_SSPI=ON -DCURL_LIBRARY=libcurl -DCURL_INCLUDE_DIR=..\libs\curl\include -DBUILD_TESTING=OFF -DBUILD_CURL_EXE=OFF -DUSE_MANUAL=OFF
- 完成后,可以在
build/cppchallenger.sln找到 Visual Studio 解决方案。
如果要将目标定为 x64 平台,可以使用名为"Visual Studio 15 2017 Win64"的生成器。Visual Studio 2017 15.4 同时支持filesystem(作为实验性库)和std::optional。如果您使用之前的版本,或者只想使用 Boost 库,可以使用以下命令生成项目,前提是您已经正确安装了 Boost:
cmake -G "Visual Studio 15 2017" .. -DCMAKE_USE_WINSSL=ON -DCURL_WINDOWS_SSPI=ON -DCURL_LIBRARY=libcurl -DCURL_INCLUDE_DIR=..\libs\curl\include -DBUILD_TESTING=OFF -DBUILD_CURL_EXE=OFF -DUSE_MANUAL=OFF -DBOOST_FILESYSTEM=ON -DBOOST_OPTIONAL=ON -DBOOST_INCLUDE_DIR=<path_to_headers> -DBOOST_LIB_DIR=<path_to_libs>
确保头文件和静态库文件的路径不包含尾随反斜杠(即\)。
下载示例代码文件
您可以从您在www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
请在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明进行操作。
文件下载后,请确保使用最新版本的解压缩软件解压缩文件夹:
-
WinRAR/7-Zip 适用于 Windows
-
Zipeg/iZip/UnRarX 适用于 Mac
-
7-Zip/PeaZip 适用于 Linux
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Modern-C-plus-plus-Efficient-and-Scalable-Application-Development。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的书籍和视频目录,可在**github.com/PacktPublishing/**上找到。去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/Cplusplus_Efficient_and_Scalable_Application_Development.pdf
使用的约定
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“作者打算输入c = a + 8 / b + 1;,但是他们按逗号键而不是斜杠键。”
代码块设置如下:
inline auto mult(int lhs, int rhs) -> int
{
return lhs * rhs;
}
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
if (op == ',' || op == '.' || op < '+' || op > '/')
{
cout << endl << "operator not recognized" << endl;
usage();
return 1;
}
任何命令行输入或输出都以以下方式编写:
C:\Beginning_C++Chapter_02\cl /EHsc calc.cpp
粗体:新术语和重要单词以粗体显示。例如,屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“函数的调用约定决定了调用函数或被调用函数负责执行此操作。”
警告或重要说明会以这种方式出现。
技巧和窍门会以这种方式出现。
理解语言特性
在本章中,您将深入了解各种语言特性,以控制代码中的流程。
编写 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 的书(丹尼斯·里奇是 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)代码。
阿尔曼风格(以开发者埃里克·阿尔曼命名)将开放大括号放在新的一行上,因此嵌套示例如下所示:
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
}
阿尔曼风格通常由微软使用。
请记住,您的代码不太可能以纸质形式呈现,因此 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到右侧的分号)是一个语句。
每个表达式都是左值或右值。你很可能会在错误描述中看到这些关键词的使用。实际上,左值是一个指向某个内存位置的表达式。赋值语句的左侧必须是左值。然而,左值可以出现在赋值语句的左侧或右侧。所有变量都是左值。右值是一个临时项,它的存在不会超过使用它的表达式;它将有一个值,但不能对其进行赋值,因此它只能存在于赋值语句的右侧。文字常量是右值。以下是左值和右值的一个简单示例:
int i;
i = 6 * 7;
在第二行,i是一个左值,表达式6 * 7的结果是一个右值(42)。以下代码不会编译,因为左边有一个右值:
6 * 7 = i;
广义上说,通过在表达式后添加分号,表达式变成了语句。例如,以下两者都是语句:
42;
std::sqrt(2);
第一行是一个右值42,但由于它是临时的,所以没有影响。C++编译器会将其优化掉。第二行调用标准库函数来计算2的平方根。同样,结果是一个右值,且值没有被使用,所以编译器会将其优化掉。然而,这说明了一个函数可以被调用而不使用其返回值。虽然对于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++的变量名区分大小写,前2,048个字符是有效的。你可以用下划线开头的变量名,但不能使用两个下划线,也不能使用下划线后面跟大写字母(这些被 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函数赋值。由于这个常量是在函数外声明的,它是文件中的全局变量,可以在整个文件中使用。
这种方法的问题在于预处理器进行简单的替换。使用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;
}
这段代码包含了两个源文件(version.cpp和print.cpp)的 C++代码,以及一个被两个源文件包含的头文件(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.3333(或4:3)时,这给出了一个纵横比为1。为了确保执行浮点除法,而不是整数除法,你可以将被除数或除数(或两者)转换为浮点数。
增量和减量运算符
这些运算符有两个版本,前缀和后缀。顾名思义,前缀意味着运算符放在操作数的左边(例如++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,因此它们被视为没有符号位。
&运算符是按位与,这意味着左操作数中的每个位与右操作数中相同位置的位进行比较。如果两者都是 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进行按位&运算与提供一个掩码的效果,掩盖除了最低字节之外的所有内容。
按位或运算符|将在相同位置的一个或两个位中有 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)将只包含test变量中也在flags中设置的位。因此,如果结果非零,这意味着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,这可能会进一步改变代码中的逻辑。避免此错误的方法是利用赋值的要求,左侧必须是 lvalue。执行测试如下:
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)的类型。
如果条件运算符返回的值是 lvalue,则可以将其用于赋值的左侧。这意味着您可以编写以下非常奇怪的代码:
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语句,即使它们已经为不同的情况指定:
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是为另一个值指定的。
通常对不同情况有不同的代码,因此您最常用break结束case。很容易因疏忽而忽略break,这将导致异常行为。在故意省略break语句时,最好在代码中进行记录,以便您知道如果缺少break,那很可能是一个错误。
对于每个case,可以提供零个或多个语句。如果有多个语句,则所有这些语句都将针对该特定情况执行。如果不提供语句(例如此示例中的case 4),则意味着不会执行任何语句,甚至不会执行default子句中的语句。
break语句表示跳出此代码块,并且在while和for循环语句中也是如此。您可以以其他方式跳出switch。case可以调用return来结束声明switch的函数;它可以调用goto跳转到标签;或者它可以调用throw抛出一个异常,该异常将被switch之外的异常处理程序捕获,甚至是函数之外。
到目前为止,情况是按数字顺序排列的。这不是必需的,但它确实使代码更易读,并且显然,如果您想穿透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子句后面的分号。这是必需的。
这个循环将以倒序打印 5 到 1。原因是循环从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;
现在,当用户将零作为除数传递时,您将收到“除以零!”的消息。
现在可以编译完整的示例并进行测试。该应用程序支持使用+、-、*和/运算符进行浮点运算,并将处理除以零的情况。
总结
在本章中,您已经学会了如何格式化代码,以及如何识别表达式和语句。您已经学会了如何识别变量的范围,以及如何将函数和变量的集合分组到命名空间中,以便防止名称冲突。您还学会了 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部分存储一个副本,因此此代码的结果将是在控制台上打印两次单个地址。
在这段代码中,变量p1和p2是自动变量,也就是说,它们是在为当前函数创建的堆栈上创建的。当调用函数时,为函数分配一块内存,其中包含为函数传递的参数和调用函数的代码的返回地址,以及函数中声明的自动变量的空间。当函数完成时,堆栈帧被销毁。
函数的调用约定决定了是调用函数还是被调用函数负责执行此操作。在 Visual C++中,默认的是__cdecl调用约定,这意味着调用函数清理堆栈。__stdcall调用约定由 Windows 操作系统函数使用,并且堆栈清理由被调用函数执行。更多细节将在下一章中给出。
自动变量只在函数持续时间内存在,这样的变量的地址只在函数内有任何意义。在本章后面,您将看到如何创建数据数组。作为自动变量分配的数组在堆栈上分配,其大小在编译时确定。对于大型数组,您可能会超出堆栈的大小,特别是在递归调用的函数中。在 Windows 上,默认堆栈大小为 1 MB,在 x86 Linux 上为 2 MB。Visual C++允许您使用/F编译器开关(或/STACK链接器开关)指定更大的堆栈。gcc 编译器允许您使用--stack开关更改默认堆栈大小。
内存的最后一种类型是在自由存储或有时称为堆上创建的动态内存。这是使用内存的最灵活方式。正如其名称所示,您在运行时分配运行时确定大小的内存。自由存储的实现取决于 C++的实现,但您应该将自由存储视为具有与应用程序相同的生存期,因此从自由存储分配的内存应该至少持续与应用程序一样长的时间。
然而,这里存在潜在的危险,特别是对于长期存在的应用程序。从自由存储器分配的所有内存应在使用完毕后返回到自由存储器,以便自由存储器管理器可以重用内存。如果不适当地返回内存,那么潜在地自由存储器管理器可能会耗尽内存,这将促使它向操作系统请求更多内存,因此,应用程序的内存使用量将随时间增长,导致由于内存分页而引起的性能问题。
指针算术
指针指向内存,指针的类型决定了可以通过指针访问的数据的类型。因此,int*指针将指向内存中的整数,并且您可以通过解引用指针(*)来获取整数。如果指针允许(未标记为const),则可以通过指针算术更改其值。例如,您可以增加或减少指针。内存地址的值取决于指针的类型。由于类型化指针指向类型,任何指针算术都将以该类型的大小单位更改指针。
如果您增加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),它将得到一个双指针的大小,而不是一个四个int数组的 16 字节大小。
数组参数的衰变为指针特性意味着函数只会在您明确告诉它大小时才知道数组参数的大小。您可以使用一对空方括号来指示该项应该传递一个数组,但它实际上只是一个指针:
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
指针cp1和cp2可以用来更改它们指向的内存,但一旦分配,它们都不能指向其他内存。第一个const_cast去除了const,使指针可以更改为指向其他内存,但不能用于更改那个内存,ptc。第二个const_cast去除了ptc的const,以便通过指针更改内存,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_cast将int*指针转换为char*指针,即使int和char都是整数类型。对于通过继承相关的自定义类型,你可以使用static_cast进行指针转换,但没有运行时检查来确保转换是正确的。要进行带有运行时检查的转换,应该使用dynamic_cast,更多细节将在第四章中给出,类。
reinterpret_cast操作符是转换操作符中最灵活、也是最危险的,因为它可以在任何指针类型之间进行转换,而不进行任何类型检查。它本质上是不安全的。例如,以下代码使用文字初始化了一个宽字符数组。数组wc将有六个字符,hello后面是NULL。wcout对象将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对象,它将打印字符,而不是数字值。因此,为了打印出各个字符的代码,我们需要将指针转换为合适的整数指针。这段代码假设short和wchar_t大小相同:
wcout << "The characters are:" << endl;
short* ps = reinterpret_cast<short*>(wc);
do
{
wcout << *ps << endl;
} while (*ps++);
在代码中分配内存
C++定义了两个运算符,new和delete,它们从自由存储中分配内存并将内存释放回自由存储。
分配单个对象
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调用约定。通常,您会看到WINAPI的typedef用于__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,因此函数不会更改它指向的字符串;这意味着它是一个in参数。此参数用于传递要获取的环境变量的名称。另一个参数只是一个char*指针,因此它可以用于将字符串传递给函数或从函数传出,或者两者都可以。了解如何使用此参数的唯一方法是阅读文档。在这种情况下,它是一个out参数;如果变量存在,函数将返回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++标准库提供了两个容器,通过索引器可以随机访问数据。这两个容器还允许您访问底层内存,并且由于它们保证以顺序和连续的方式存储项目,因此当您需要提供指向缓冲区的指针时,可以使用它们。这两种类型都是模板,这意味着您可以使用它们来保存内置类型和自定义类型。这两个集合类是array和vector。
使用基于堆栈的数组类
array类定义在<array>头文件中。该类允许您在堆栈上创建固定大小的数组,并且与内置数组一样,它们不能在运行时收缩或扩展。由于它们是在堆栈上分配的,因此它们不需要在运行时调用内存分配器,但显然,它们应该比堆栈帧大小小。这意味着array是小数组的一个很好的选择。array的大小必须在编译时知道,并且作为模板参数传递:
array<int, 4> arr { 1, 2, 3, 4 };
在此代码中,尖括号(<>)中的第一个模板参数是数组中每个项目的类型,第二个参数是项目的数量。此代码使用初始化列表初始化数组,但请注意,您仍然必须在模板中提供数组的大小。此对象将像内置数组一样工作(或者说,任何标准库容器),可以使用范围for:
for (int i : arr) cout << i << endl;
原因是array实现了所需的begin和end函数,因此可以使用这种语法。您还可以使用索引来访问项目:
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引用,但对于自定义类型,如果复制会有开销,那么有一个优势,正如你在这里看到的,编译器会在需要时退回到创建一个临时对象。
右值引用
C++11 定义了一种新类型的引用,即右值引用。在 C++11 之前,代码(比如赋值运算符)无法知道传递给它的右值是临时对象还是其他。如果这样的函数传递了一个对象的引用,那么函数必须小心不要改变引用,因为这会影响到它所引用的对象。如果引用是指向临时对象的,那么函数可以对临时对象做任何想做的事情,因为对象在函数完成后不会存在。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;
}
字符串对象有一个索引运算符([]),因此可以将其视为字符数组,既可以读取字符的值,也可以给字符位置赋值。通过length函数获取字符串的大小,该函数返回一个unsigned int(typedef为size_t)。由于参数是引用,这意味着对字符串的任何更改都将反映在传递给函数的字符串中。这段代码的意图是保持其他变量不变,因此首先对参数进行复制。然后在副本上,代码遍历所有字符,将a、b和o字符更改为下划线,然后打印出结果。
这段代码显然有一个复制开销——从引用rs创建string s,但如果我们想将get_global或get_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_global和get_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*的begin或end函数。原因是范围for使用迭代器对象,对于数组,它使用 C++标准库函数begin和end来创建这些对象。编译器会从外部范围for中的arr数组中看到每个项目都是一个int[3]数组,所以在外部for循环中,循环变量将是每个元素的副本,在这种情况下是一个int[3]数组。你不能像这样复制数组,所以编译器会提供指向第一个元素的指针,一个int*,并且这在内部for循环中使用。
编译器会尝试为int*获取迭代器,但这是不可能的,因为int*不包含指向多少个项目的信息。对于int[3](以及所有大小的数组)定义了begin和end的版本,但对于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;
}
第一行检查列表是否为空。如果pHead是nullptr,这意味着没有其他链接,因此当前链接是第一个链接,因此pHead和pCurrent都初始化为新链接指针。如果列表中存在现有链接,则链接必须添加到最后一个链接,因此在else子句中,第一行使最后一个链接指向新链接,第二行使用新链接指针初始化pCurrent,使新链接成为列表中任何新插入的最后一个链接。
通过在main函数中调用此函数,可以将项目添加到列表中。在此示例中,我们将排队任务以粉刷房间。这涉及去除旧壁纸,填补墙上的任何孔洞,调整墙的大小(用稀释的糊糊涂抹墙使墙变得粘性),然后将粘贴的壁纸挂在墙上。您必须按照这个顺序执行这些任务,不能更改顺序,因此这些任务非常适合使用链表。在main函数中添加以下行:
queue_task("remove old wallpaper");
queue_task("fill holes");
queue_task("size walls");
queue_task("hang new wallpaper");
在最后一行之后,列表已经创建。pHead变量指向列表中的第一项,您可以通过简单地跟随pNext成员从一个链接到下一个链接来访问列表中的任何其他项。
您可以编译代码,但没有输出。更糟糕的是,代码的当前状态下存在内存泄漏。程序没有代码来delete由new运算符在自由存储器上创建的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,则意味着所需的任务不在列表中。
在main函数中创建列表后,添加以下代码以搜索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++程序员,您是一种特殊类型的程序员:一个被信任处理内存的人。在本章中,您已经了解了如何获取和使用指针,以及指针的不当使用如何使您的代码出现严重错误的一些示例。
在下一章中,我们将涵盖函数,其中将包括另一种指针的描述:函数指针。如果你被信任处理数据指针和函数指针,那么你真的是一种特殊类型的程序员。