C---基础知识-全-
C++ 基础知识(全)
原文:
zh.annas-archive.org/md5/b7a01aca57a37dc90e268b7ba3eb4016
译者:飞龙
第一章:前言
关于
本节简要介绍了作者、本书涵盖的内容、开始学习所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。
关于本书
C++基础知识 从介绍 C++的编译模型和语法开始。然后,你将学习数据类型、变量声明、作用域和控制流语句。借助这本书,你将能够编译完整的 C++代码,并理解如何使用变量、引用和指针来操纵程序的状态。接下来,你将探索函数和类——C++提供用于组织程序的功能——并使用它们来解决更复杂的问题。你还将了解常见的陷阱和现代最佳实践,特别是那些与 C++98 指南相背离的。
随着你对章节的深入,你将研究泛型编程的优势,并编写自己的模板以创建适用于任何类型的泛型算法。这本书将指导你充分利用标准容器和算法,理解如何为每个问题选择合适的工具。
到本书结束时,你不仅能够编写高效的代码,而且还将具备提高程序可读性、性能和可维护性的能力。
关于作者
弗朗西斯科·佐夫利是一位在伦敦的 Bloomberg LP 工作的专业软件工程师。他在米兰理工大学获得计算机科学与工程硕士学位后,进入金融行业,对现代 C++产生了浓厚的兴趣。他对编程语言、可维护的软件和大型分布式系统充满热情。他在项目以及日常工作中使用 C++来交付可扩展、高效和弹性的系统。
安东尼奥·马拉亚是一位拥有五年行业经验的 C++爱好者,目前在美国纽约市的纽约大学坦登工程学院攻读计算机科学博士学位。他的研究兴趣主要与信息检索相关,特别关注提高大规模系统的效率。因此,C++在他的大多数项目中扮演着至关重要的角色,并有助于它们的成功。
目标
-
C++编译模型
-
应用编写函数和类的最佳实践
-
使用模板编写安全、泛型和高效的代码
-
探索 C++标准提供的容器
-
发现 C++11、C++14 和 C++17 引入的新特性
-
掌握 C++的核心语言特性
-
使用 C++面向对象编程解决复杂问题
读者对象
如果您是一位希望学习一种新强大语言的开发者,或者熟悉 C++ 但想通过 C++11、C++14 和 C++17 的现代范式更新您的知识,这本书适合您。为了轻松理解书中的概念,您必须熟悉编程的基础知识。
方法
C++ 基础 完美地平衡了理论与练习。每个模块都旨在在前一个模块的基础上构建。本书包含多个活动,使用真实商业场景供您练习,并在高度相关的环境中应用您的新技能。
最小硬件要求
为了获得最佳的学生体验,我们建议以下硬件配置:
-
处理器:Intel Core i3 或等效处理器
-
内存:4 GB RAM
-
存储:10 GB 可用空间
软件要求
您还需要提前安装以下软件:
-
操作系统:任何桌面 Linux 版本或 macOS,或 Windows 7、8.1 或 10
-
针对 Windows 10 系统:Windows 子系统 for Linux(仅在最新版本中可用)
-
浏览器:使用最新的浏览器之一,例如 Firefox、Chrome、Safari、Edge 或 IE11
-
现代 C++编译器
其他资源
本书代码包托管在 GitHub 上,网址为 github.com/TrainingByPackt/Cpp-Fundamentals
。
我们还提供其他丰富的书籍和视频的代码包,可在 github.com/PacktPublishing/
找到。查看它们!
惯例
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:“创建一个名为 HelloUniverse.cpp
的文件并将其保存。”
代码块设置如下:
#include <iostream>
int main() {
std::cout << "Hello Universe" << std::endl;
return 0;
}
新术语和重要单词以粗体显示。屏幕上出现的单词,例如在菜单或对话框中,在文本中显示如下:“从 管理 面板中选择 系统 信息。”
第二章:第一章
入门
课程目标
到本章结束时,你将能够:
-
解释 C++编译模型
-
执行
main()
函数 -
说明变量的声明和定义
-
确定内置算术类型、引用和指针
-
解释变量的作用域
-
使用控制流语句
-
定义并使用数组
在本章中,你将学习如何使用变量和控制流语句来创建更健壮的程序。
简介
C++在软件开发行业中已经是一个主要角色超过 30 年,支持世界上一些最成功的公司。
近年来,对该语言的需求比以往任何时候都要增长,它是一个极其流行的选择,适用于大规模系统,许多大型公司都赞助其发展。
C++仍然是一种复杂的语言,它将大量权力交到开发者手中。然而,这也带来了很多犯错的机会。它是一种独特的语言,因为它能够使程序员编写高级抽象,同时保留对硬件、性能和可维护性的完全控制。
C++编译模型
了解 C++编译工作原理是理解程序如何编译和执行的基础。将 C++源代码编译成机器可读代码包括以下四个过程:
-
预处理源代码。
-
编译源代码。
-
汇编编译后的文件。
-
将目标代码文件链接以创建可执行文件。
让我们从简单的 C++程序开始,了解编译是如何发生的。
创建一个名为HelloUniverse.cpp
的文件,并将以下代码复制粘贴到桌面后保存:
#include <iostream>
int main(){
// This is a single line comment
/* This is a multi-line
comment */
std::cout << "Hello Universe" << std::endl;
return 0;
}
现在,使用终端上的cd
命令导航到文件保存的位置,如果你使用的是 UNIX 系统,请执行以下命令:
> g++ -o HelloUniverse HelloUniverse.cpp
> ./HelloUniverse
如果你使用的是 Windows 系统,必须使用不同的编译器。使用 Visual Studio 编译器编译代码的命令如下:
> cl /EHsc HelloUniverse.cpp
> HelloUniverse.exe
此程序一旦执行,将在终端上打印Hello Universe
。
让我们使用以下图表来揭示 C++编译过程:
图 1.1:HelloUniverse 文件的 C++编译
-
当 C++预处理器遇到
#include <file>
指令时,它将其替换为文件的内容,创建一个扩展的源代码文件。 -
然后,这个扩展的源代码文件被编译成平台的汇编语言。
-
编译器生成的文件被汇编器转换成目标代码文件。
-
此目标代码文件与任何库函数的目标代码文件链接在一起,生成可执行文件。
标题文件和源文件的差异
源文件包含实际的实现代码。源文件通常具有.cpp
扩展名,尽管其他扩展名如.cc
、.ccx
或.c++
也很常见。
另一方面,头文件包含描述可用功能的代码。这些功能可以通过源文件中的可执行代码进行引用和使用,允许源文件知道在其他源文件中定义了哪些功能。头文件最常见的扩展名是.hpp
、.hxx
和.h
。
要从头文件和源文件创建可执行文件,编译器首先预处理它们包含的指令(以#
符号开头,通常位于文件顶部)。在先前的HelloUniverse
程序中,指令将是#include
。在真正的编译之前,编译器会对其进行预处理,并将其替换为iostream
头文件的内容,该文件描述了从流中进行读取和写入的标准功能。
第二步是处理每个源文件,并生成包含与该源文件相关的机器代码的对象文件。最后,编译器将所有对象文件链接成一个单独的可执行程序。
我们看到,预处理器将指令的内容转换为源文件。头文件也可以包含其他头文件,这将进行展开,创建一个展开链。
例如,让我们假设logger.hpp
头文件的内容如下:
// implementation of logger
让我们也假设calculator.hpp
头文件的内容如下:
#include <logger.hpp>
// implementation of calculator
在main.cpp
文件中,我们包含了两个指令,如下面的代码片段所示:
#include <logger.hpp>
#include <calculator.hpp>
int main() {
// use both the logger and the calculator
}
展开的结果将如下所示:
// implementation of logger
// implementation of logger
// implementation of calculator
int main() {
// use both the logger and the calculator
}
如我们所见,日志记录器在结果文件中添加了两次:
-
它是在第一次添加的,因为我们已经在
main.cpp
文件中包含了logger.hpp
。 -
它是在第二次添加的,因为我们包含了
calculator.hpp
,然后它又包含了logger.hpp
。
在我们编译的文件中未直接指定在#include
指令中,而是由其他包含文件包含的包含文件,被称为间接包含文件。
通常,包含相同的头文件多次会创建一个多定义问题,正如我们将在第 2 课,函数和第 03 课,类中看到的那样。
由于我们之前解释的间接包含文件,多次包含相同的文件很可能,并且通常会引发编译错误。在 C++中,有一个约定可以防止由于多次包含头文件而产生的问题:包含保护器。
包含保护器是一种特定的指令模式,指示预处理器在之前已包含的情况下忽略头文件的内容。
它包括在以下结构内部编写所有头文件代码:
#ifndef <unique_name>
#define <unique_name>
// all the header code should go here
#endif /* <unique_name> */
这里,<unique_name>
是在整个 C++项目中唯一的名称;它通常由头文件名组成,例如logger.hpp
头文件的LOGGER_HPP
。
上述代码检查一个特殊的预处理变量 <unique_name>
是否存在。如果不存在,它将定义它并继续读取头文件的内容。如果存在,它将跳过直到 #endif
部分的所有代码。
由于特殊变量最初不存在,预处理程序第一次包含头文件时,它会创建该变量并继续读取文件。随后的时间,变量已经定义,因此预处理程序会跳转到 #endif
指令,跳过头文件的所有内容。
编译是一个确保程序在语法上正确的过程,但它不会对程序的逻辑正确性进行检查。这意味着编译正确的程序可能仍然会产生不期望的结果:
图 1.2:可执行文件的编译和链接过程
每个 C++ 程序都需要定义一个起点,即执行应该从代码的哪个部分开始。惯例是在源代码中有一个唯一命名的 main
函数,它将是首先被执行的部分。这个函数由操作系统调用,因此它需要返回一个表示程序状态的值;因此,它也被称为 退出状态码
。
让我们看看我们如何编译一个程序。
与 C 语言一样,C++ 是支持硬件和平台最多的语言。这意味着有许多由不同供应商生产的 C++ 编译器。每个编译器可以以不同的方式接受参数,因此在 C++ 开发时,了解你所使用的编译器的可用选项及其含义非常重要。
我们现在将看到如何使用两个最常用的编译器编译程序:Microsoft Visual Studio 编译器和 GCC。
将文件编译成目标文件
要将 myfile.cpp
文件编译成名为 myfile.obj
的目标文件,我们可以运行以下命令:
图 1.3:编译 CPP 文件
在编译时,通常包括一些头文件。
我们可以不执行任何操作就包含 C++ 标准中定义的头文件,但如果我们想包含用户定义的头文件,我们需要告诉编译器在哪些文件夹中查找头文件。
对于 MSVC,你需要将参数作为 /I path
传递,其中 path
是要查找头文件的目录路径。
对于 GCC,你需要将参数作为 -I path
传递,其中 path
的含义与 MSVC 相同。
如果 myfile.cpp
在 include
目录中包含一个头文件,我们将使用以下命令编译该文件:
图 1.4:使用包含目录编译 CPP 文件
我们可以将多个文件编译成它们各自的目标文件,然后将它们全部链接起来以创建最终的应用程序。
链接目标文件
要将两个名为 main.obj
和 mylib.obj
的目标文件链接成一个可执行文件,我们可以运行以下命令:
图 1.5:编译两个目标文件
使用 MSVC,我们将创建一个名为 main.exe
的可执行文件,而使用 g++
,可执行文件将被命名为 main
。
为了方便,MSVC 和 GCC 提供了一种方法,可以将多个文件编译成一个可执行文件,而无需为每个文件创建一个目标文件,然后再将文件链接起来。
即使在这种情况下,如果文件包含任何用户定义的头文件,你也需要使用 /I
或 -I
标志指定头文件的位置。
要将 main.cpp
和 mylib.cpp
文件一起编译,这些文件使用 include
文件夹中的某些头文件,你可以使用以下命令:
图 1.6:包含文件夹的文件编译
与 main
函数一起工作
在下一章中,我们将更深入地讨论函数;现在,我们可以以以下方式定义 main
函数,它什么都不做,除了返回一个成功状态码:
int main()
{
return 0;
}
第一行包含函数的定义,由返回类型 int
、main
函数的名称以及参数列表组成,在这个例子中是一个空列表。然后,我们有了函数的主体,由花括号界定。最后,主体由一条将返回成功状态码的单个指令组成。
注意
与 C 语言不同,在 C++ 程序中,返回语句是可选的。如果你没有明确返回值,编译器会自动添加 return 0
。
我们将在稍后更详细地讨论这些主题;重要的是要知道这是一个有效的 C++ 程序,它可以被编译和执行。
注意
大多数 C 编译器可以通过确定文件扩展名来编译 C 或 C++ 语言。
练习 1:编译和执行 main
函数
在这个练习中,我们将创建一个名为 main.cpp
的源文件,其中包含代码。编译该文件并运行程序。我们将使用它来探索 C++ 环境:
-
使用你喜欢的文本编辑器(如果你使用 Windows,可以是 Sublime Text、Visual Studio Code、Atom 或 Notepad++),创建一个新文件并将其命名为
main.cpp
。 -
在
main.cpp
文件中写下以下代码并保存:int main() { return 0; }
-
使用以下命令编译
main.cpp
文件://On UNIX: > g++ main.cpp //On Windows: > cl /EHsc main.cpp
-
编译过程将生成一个可执行文件,在 Windows 系统上命名为
main.exe
,在 UNIX 系统上命名为main.out
。
内置数据类型
在大多数编程语言中,数据存储在变量中,变量是程序员定义的内存部分的标签。每个变量都有一个关联的类型。类型定义了变量可以持有哪种类型的值。
C++ 的内置数据类型分为两类:
-
原始数据类型:可以直接由用户声明变量
-
抽象或用户定义的数据类型:由用户定义,例如,在 C++ 中定义一个类或结构
原始数据类型
原始数据类型包括以下类型:
-
int
类型存储一个从-2147483648
到2147483647
的整数。此数据类型通常占用4
字节内存空间。 -
char
类型存储字符数据。它保证足够大,可以表示任何 UTF-8 单字节代码单元;对于 UTF-16 和 UTF-32,分别使用char16_t
和char32_t
。char
通常占用 1 字节内存空间。 -
bool
数据类型能够存储两个值之一:true
或false
。 -
float
类型用于存储单精度浮点值。此数据类型通常占用 4 字节内存空间。 -
double
类型用于存储双精度浮点值。此数据类型通常占用 8 字节内存空间。 -
void
类型是一个无值的类型,用于不需要返回值的函数。 -
wchar_t
类型也用于表示字符集,但允许更大的尺寸。虽然char
支持介于 8 到 32 位的字符,但宽字符是 2 到 4 字节长。
字符类型 char
和 wchar_t
包含与机器字符集中的字符相对应的数值。
数据类型修饰符
C++ 编程语言提供的数值类型分为三类:
-
带符号
-
无符号
-
浮点数
带符号和无符号的类型有不同的尺寸,这意味着每个都可以表示更小或更大的值范围。
整数类型可以是带符号的或无符号的,其中带符号的类型可以用来区分负数或正数,而无符号的只能表示大于或等于零的数。
signed
关键字是可选的;程序员只有在类型是无符号的情况下才需要指定它。因此,signed int
和 int
是相同的类型,但它们与 unsigned int
或简称 unsigned
不同。确实,如果没有指定,无符号类型始终默认为 int
。
如前所述,整数可以有不同的尺寸:
-
int
-
short int
-
long int
-
long long int
short int
类型,或简称 short
,根据标准保证至少为 16 位。这意味着它可以存储从 -32768
到 32767
范围内的值。如果它也是 unsigned
,那么就是 unsigned short int
或简称 unsigned int
,这个范围将是 0
到 65535
。
注意
类型在内存中的有效尺寸可以根据代码编译的平台而改变。C++ 存在于许多平台,从数据中心中的超级计算机到工业环境中的小型嵌入式芯片。为了能够支持所有这些不同类型的机器,标准只为内置类型设定了最小要求。
变量定义
变量是一个名为存储的命名空间,它指向内存中的一个位置,可以用来存储值。C++是一种强类型语言,它要求在第一次使用之前必须声明每个变量的类型。
变量的类型被编译器用来确定需要预留的内存以及解释其值的方式。
以下语法用于声明一个新变量:
type variable_name;
C++中的变量名可以包含字母表中的字母,大小写均可,数字和下划线(_)。虽然允许使用数字,但不能作为变量名的第一个字符。可以通过列出变量名(用逗号分隔)在同一语句中声明相同类型的多个变量:
type variable_name1, variable_name2, …;
这相当于以下:
type variable_name1;
type variable_name2;
type ...;
在声明变量时,其值在执行赋值之前是不确定的。也可以声明一个具有给定值的变量;这种操作也称为变量初始化。
初始化变量的一个方法——也称为C 样式初始化——使用以下语法:
type variable_name = value;
另一种解决方案是构造函数初始化,我们将在第 3 课,类中详细看到。构造函数初始化看起来是这样的:
type variable_name (value);
一致初始化或列表初始化引入了花括号初始化,这允许对不同类型的变量和对象进行初始化:
type variable_name {value};
揭秘变量初始化
当一个变量被初始化时,编译器可以确定存储提供的值所需的数据类型,这意味着不需要指定变量的类型。编译器确实能够推导出变量的类型,因此这个特性也被称为类型推导。因此,引入了auto关键字来替换初始化期间的类型名称。初始化语法变为如下:
auto vvariable_name = value;
避免直接提供类型的一种方法是用decltype
指定符。它用于推导给定实体的类型,并使用以下语法编写:
type variable_name1;
decltype(variable_name1) variable_name2;
在这里,variable_name2
是根据从variable_name1
推导出的类型声明的。
注意
使用auto
和decltype
关键字进行类型推导是由 C++11 标准引入的,以简化在无法获得类型时变量的声明。但与此同时,在不真正需要时它们的扩展使用可能会降低代码的可读性和健壮性。我们将在第 4 课,泛型编程和模板中更详细地看到这一点。
在以下代码中,我们将通过创建一个名为main.cpp
的新源文件并逐行分析代码来检查变量的有效语句。
以下哪个是有效的语句?
int foo;
auto foo2;
int bar = 10;
sum = 0;
float price = 5.3 , cost = 10.1;
auto val = 5.6;
auto val = 5.6f;
auto var = val;
int a = 0, b = {1} , c(0);
指针和引用
在上一节中,变量被定义为可以通过其名称访问的内存部分。这样,程序员不需要记住保留的内存位置和大小,但可以方便地引用变量名。
在 C++ 中,获取变量实际内存地址的方法是在变量名前加上一个井号符号(&
),这被称为取地址运算符。
使用取地址运算符概念的正确语法如下:
&variable_name
在代码中使用这将返回变量的物理内存地址。
指针
能够在 C++ 中存储内存地址的数据结构被称为指针。指针始终指向特定类型的对象,因此我们在声明指针时需要指定所指向的对象的类型。
声明指针的语法如下:
type * pointer_name;
当涉及到指针时,同一语句中也可以有多个声明,但重要的是要记住每个指针声明都需要一个星号(*
)。以下是一个多个指针声明的示例:
type * pointer_name1, * pointer_name2, *...;
当只指定第一个声明时,两个变量将具有不同的类型。例如,在以下声明中,只有前者是指针:
type * pointer_name, pointer_name;
注意
不论指向的变量类型如何,指针在内存中总是占据相同的大小。这源于指针所需的内存空间与变量存储的值无关,而是与平台相关的内存地址有关。
直观地说,指针赋值的语法与其他任何变量相同:
pointer_name = &variable_name;
之前的语法将 variable_name
变量的内存地址复制到名为 pointer_name
的指针中。
以下代码片段首先将 pointer_name1
初始化为 variable_name
的内存地址,然后将 pointer_name2
初始化为 pointer_name1
中存储的值,即 variable_name
的内存地址。因此,pointer_name2
最终将指向 variable_name
变量:
type * pointer_name1 = &variable_name;
type * pointer_name2 = pointer_name1;
以下实现是无效的:
type * pointer_name1 = &variable_name;
type * pointer_name2 = &pointer_name1;
这次,pointer_name2
将被初始化为 pointer_name1
的内存地址,从而产生一个指向另一个指针的指针。将指针指向另一个指针的方法是使用以下代码:
type ** pointer_name;
两个星号(*
)表示所指向的类型现在是一个指针。一般来说,语法只需要在指针声明的每个间接级别前使用一个星号(*
)。
要访问给定内存地址的实际内容,可以使用解引用运算符(*
),后跟内存地址或指针:
type variable_name1 = value;
type * pointer_name = &variable_name1;
type variable_name2 = *pointer_name;
variable_name2
包含的值与 variable_name1
包含的值相同。赋值时也是如此:
type variable_name1 = value1;
type * pointer_name = &variable_name1;
*pointer_name = value2;
引用
与指针不同,引用只是一个对象的别名,这本质上是一种给现有变量起另一个名字的方式。定义引用的方式如下:
type variable_name = value;
type &reference_name = variable_name;
让我们检查以下示例:
#include <iostream>
int main()
{
int first_variable = 10;
int &ref_name = first_variable;
std::cout << "Value of first_variable: " << first_variable << std::endl;
std::cout << "Value of ref_name: " << ref_name << std::endl;
}
//Output
Value of first_variable: 10
Value of ref_name: 10
我们可以将指针与以下三个主要区别进行识别:
-
一旦初始化,引用就会绑定到其初始对象。因此,不可能将引用重新赋值给另一个对象。对引用执行的所有操作实际上是对被引用的对象的操作。
-
由于没有重新绑定引用的可能性,因此必须对其进行初始化。
-
引用始终与存储在内存中的变量相关联,但该变量可能无效,在这种情况下,不应使用该引用。我们将在第 6 课,面向对象编程中看到更多关于这一点的内容。
可以定义多个对同一对象的引用。由于引用不是一个对象,因此不可能有对另一个引用的引用。
在以下代码中,假设a
是一个整数,b
是一个浮点数,p
是一个指向整数的指针,验证哪些变量初始化是有效和无效的:
int &c = a;
float &c = &b;
int &c;
int *c;
int *c = p;
int *c = &p;
int *c = a;
int *c = &b;
int *c = *p;
常量限定符
在 C++中,可以定义一个变量,其值一旦初始化后就不会被修改。通知编译器这种情况的方式是通过const
关键字。声明和初始化const
变量的语法如下:
const type variable_name = value;
在 C++程序中强制不可变性的原因有几个,其中最重要的原因是正确性和性能。确保变量是常量将防止编译器编译出试图意外更改该变量的代码,从而防止可能的错误。
另一个原因是,通知编译器变量的不可变性允许优化代码及其背后的逻辑。
注意
在创建对象后,如果其状态保持不变,那么这种特性被称为不可变性。
不可变性的一个例子如下:
#include <iostream>
int main()
{
const int imm = 10;
std::cout << imm << std::endl;
//Output: 10
int imm_change = 11;
std::cout << imm_change << std::endl;
//Output: 11
imm = imm_change;
std::cout << imm << std::endl;
//Error: We cannot change the value of imm
}
一个对象是不可变的,如果它的状态在对象创建后不发生变化。因此,如果一个类的实例是不可变的,那么这个类也是不可变的。我们将在第 3 课,类中了解更多关于类的内容。
现代 C++支持另一种不可变性的概念,这通过constexpr
关键字来表示。特别是,当编译器需要在编译时评估常量时,它会被使用。此外,每个被声明为constexpr
的变量都是隐式const
的。
前一个主题介绍了指针和引用;结果发现,即使是它们也可以声明为const
。以下内容相对直观易懂,其语法如下:
const type variable_name;
const type &reference_name = variable_name;
这种语法展示了我们如何声明一个对具有const
类型的对象的引用;这样的引用俗称为const 引用。
对const
的引用不能用来改变它们所引用的对象。请注意,将const
引用绑定到非const
类型是可能的,这通常用来表达所引用的对象将被用作不可变对象:
type variable_name;
const type &reference_name = variable_name;
然而,相反的情况是不允许的。如果一个对象是const
,那么它只能通过const
引用来引用:
const type variable_name = value;
type &reference_name = variable_name;
// Wrong
以下是一个例子:
#include <iostream>
int main()
{
const int const_v = 10;
int &const_ref = const_v;
//Error
std::cout << const_v << std::endl;
//Output: 10
}
就像引用一样,指针可以指向const
对象,其语法也是类似且直观的:
const type *pointer_name = &variable_name;
以下是一个例子:
#include <iostream>
int main()
{
int v = 10;
const int *const_v_pointer = &v;
std::cout << v << std::endl;
//Output: 10
std::cout << const_v_pointer << std::endl;
//Output: Memory location of v
}
const
对象的地址只能存储在指向const
的指针中,但反之则不然。我们可以有一个指向const
的指针指向一个非const
对象,在这种情况下,就像对const
的引用一样,我们无法保证对象本身不会改变,但只能保证指针不能用来修改它。
对于指针,由于它们也是对象,我们还有一个额外的案例,即const
指针。虽然对于引用来说,const
引用只是指向const
的引用的简写,但对于指针来说并非如此,它有完全不同的含义。
事实上,一个const
指针本身就是一个常量指针。在这里,指针本身并不指示指向的对象;它可能是const
或非const
,但我们不能改变的是一旦初始化后指针所指向的地址。其语法如下:
type *const pointer_name = &variable_name;
如您所见,const
关键字放在*
符号之后。记住这个规则的最简单方法是从右向左阅读,所以pointer-name > const > * > type
可以读作如下:pointer-name
是一个指向类型为type
的const
指针。以下是一个例子:
#include <iostream>
int main()
{
int v = 10;
int *const v_const_pointer = &v;
std::cout << v << std::endl;
//Output: 10
std::cout << v_const_pointer << std::endl;
//Output: Memory location of v
}
注意
指向const
和const
到指针是独立的,可以在同一个语句中表达:
const type *const pointer_name = &variable_name;
前面的行表示指向的对象和指针都是const
。
变量的作用域
正如我们已经看到的,变量名指的是程序中一个特定实体的引用。程序中这个名称具有特定意义的活跃区域也称为名称的作用域
。C++中的作用域由花括号限定,这个区域也称为块。在块外部声明的实体具有全局作用域,在代码的任何地方都是有效的:
图 1.7:变量的作用域
同一个名称可以在两个作用域中声明,并引用不同的实体。此外,一旦声明,名称就可见,直到其声明的作用域块的末尾。
让我们通过以下示例来理解全局变量和局部变量的作用域:
#include <iostream>
int global_var = 100;
//Global variable initialized
int print(){
std::cout << global_var << std::endl;
//Output: 100
std::cout << local_var << std::endl;
//Output: Error: Out of scope
}
int main()
{
int local_var = 10;
std::cout << local_var << std::endl;
//Output: 10
std::cout << global_var << std::endl;
//Output: 100
print();
//Output:100
//Output: Error: Out of scope
}
范围可以嵌套,我们分别称包含范围和被包含范围为外部范围和内部范围。外部范围内声明的名称可以在内部使用。在内部范围重新声明最初在外部范围中声明的名称是可能的。结果将是新变量将隐藏在内部范围中声明的变量。
让我们看看以下代码:
#include <iostream>
int global_var = 1000;
int main()
{
int global_var = 100;
std::cout << "Global: "<< ::global_var << std::endl;
std::cout << "Local: " << global_var << std::endl;
}
Output:
Global: 1000
Local: 100
在下一章中,我们将探讨如何使用函数中的局部和全局变量。
在以下代码中,我们将找到所有变量的值,而无需执行程序。
以下程序展示了变量初始化的工作原理:
#include <iostream>
int main()
{
int a = 10;
{
int b = a;
}
const int c = 11;
int d = c;
c = a;
}
控制流语句
在程序中,仅通过执行一系列线性操作来提供有用的功能是很少见的。通常,程序必须能够对不同的情况做出不同的反应,或者在不同的上下文中多次执行相同的操作。
现在我们将看到 C++ 为程序员提供的控制流语句,以控制要执行的运算顺序。
选择语句 – if-else
C++ 提供了条件执行支持,其中 if
关键字指示是否根据提供的条件执行后续语句或块:
if (condition) statement
如果名为 condition
的表达式评估为 true
,则执行该语句;否则,它将被忽略,程序将继续执行后续代码。
条件执行代码可以是一个单独的语句,也可以是一个包含多个语句的整个块。这些语句需要用大括号 ({}
) 括起来形成一个块:
if (condition) {
statement_1;
statement_2;
statement_N;
}
注意
常常会忘记大括号,并以以下方式编写控制语句:
if (condition)
statement1
statement2
在这种情况下,编译器不会警告你,它将根据条件执行 statement1
,但总是执行 statement2
。为了避免这种情况,始终添加大括号可能是一个好习惯。
当条件评估为 false
时,可以指定要执行的操作。这是通过 else
关键字完成的,它后面跟着一个语句或一个块。
以下语法用于指示如果 case
条件评估为 true
,则执行 statement1
,否则执行 statement2
:
if (condition) statement1 else statement2
最后,我们可以将多个 if-else 语句连接起来,以产生更复杂的分支逻辑。让我们看看以下示例:
if (condition1) {
statement1
} else if (condition2) {
statement2
} else {
statement3
}
使用这种通用结构,可以检查无限数量的条件,并只执行相应的语句或 else
分支中包含的最终语句。
重要的是要意识到,一旦满足其中一个条件,后续的所有条件都将被丢弃,例如:
if (x > 0) {
// When x is greater than 0, statement1 is executed.
// If that is not the case, the control jumps to the else block.
statement1
} else if (x > 100) {
statement2
}
之前的代码将始终为任何正数 x
执行 statement1
,无论它是否大于 100。
另一种方法是按顺序使用多个 if
关键字,如下所示:
if (condition1)
// If condition1 is true, statement1 is executed
statement1
if (condition2)
// if condition2 is true then statement2 is executed
statement2
/* independently whether condition1 and condition2 is true or not, the statement3 will be executed */
statement3
让我们通过以下示例来消除之前逻辑的神秘感:
#include <iostream>
int main()
{
int x = 10;
if (x > 0){
std::cout << x << std::endl;
}
if (x > 11 ){
std::cout << x << std::endl;
}
else{
std::cout << x-1 << std::endl;
}
}
Output:
10
9
这样,所有条件都是独立评估的,并且可能执行多个语句。
注意
由于else
语句没有定义条件,在评估if
语句之后,控制流会转到else
块以执行语句。
选择语句 – switch
另一个选择语句,与if-else
连接构造类似,是switch
语句。它限于常量表达式,主要用于检查多个可能的表达式中的一个值:
switch (expression)
{
case constant1:
group-of-statements-1;
break;
case constant2:
group-of-statements-2;
break;
...
default:
default-group-of-statements;
break;
}
在switch
关键字后面的括号中出现的expression
将与多个情况进行比较,寻找表达式与常量之间的第一个相等性。如果没有情况匹配,将执行默认情况(如果存在,因为它是可选的)。
重要的是要记住,评估顺序是顺序的,并且一旦某个常量匹配,就会执行相应的语句组。break
关键字阻止它们进一步执行。如果不包含break
关键字,则也会执行随后的所有语句,包括在不同标签下的语句。
我们将在跳转语句 – break 和 continue部分更深入地探讨break
关键字。
迭代语句 – for 循环
for
循环是一个用于重复执行语句一定次数的构造。for
循环的语法如下:
for (initialization; condition; increase){
statement1;
statement2;
...
statementN;
}
for
循环由两部分组成:初始化、条件和增加语句。主体可以是一个单独的语句或多个语句的块。
初始化语句通常(但不一定)用于声明一个新的变量,通常是一个计数器,并将其初始化为某个特定值。初始化语句仅在循环开始时执行一次。
其次,检查条件语句。这与if
语句中检查的条件类似。如果条件为true
,则执行循环体,否则程序将继续执行for
循环体之后的指令。
在执行主体之后,执行增加语句。这通常改变初始化语句的计数器。然后再次检查条件,如果为true
,则重复步骤。当条件评估为false
时,循环结束。
for
循环的头部字段是可选的,可以留空,但分号不能省略。当省略条件时,它始终评估为true
。例如,以下对应于一个无限循环,其中语句无条件执行:
for ( ; ; ) statement;
for
循环的另一种变体称为基于范围的for
循环,其语法如下:
for ( declaration : range ) statement;
范围是一系列元素,如数组,这些将在下一节中解释。这个基于范围的 for
循环用于遍历这些序列的所有元素。for
声明,名称是循环每次迭代声明的临时变量。这用于存储当前元素。声明需要与范围中包含的元素类型相同。
注意
基于范围的 for
循环是 type
推断和为声明使用 auto
关键字使代码更易读并帮助程序员找到正确类型的良好示例。
放在循环内部的循环称为 嵌套循环。让我们通过以下图表来了解什么是嵌套 for 循环:
图 1.8:嵌套 for 循环
使用以下示例,让我们探索嵌套 for 循环的工作原理,并在控制台上打印一个倒置的半三角形:
#include <iostream>
int main()
{
for (int x = 0; x < 5; x++){
for (int y = 5; y > x; y--){
std::cout << "*";
}
std::cout <<"\n" ;
}
}
Output:
*****
****
***
**
*
迭代语句 – while 循环
另一个迭代语句是 while
循环。它比 for
循环简单。它的语法如下:
while (condition) statement;
它会重复执行语句,直到满足条件。当条件不再为 true
时,循环结束,程序在循环之后继续执行:
注意
一个 while
循环可以用 for
循环表示。
这里是一个示例:for ( ; condition ; ) statement;
迭代语句 – do-while 循环
类似的循环是 do-while
循环,其中条件是在执行语句之后而不是之前进行检查的。它使用以下语法:
do statement while (condition);
即使条件永远不会评估为 true
,它也保证了至少执行一次语句。
跳转语句 – break 和 continue
break
关键字用于独立结束循环,无论它是否满足条件。在以下程序中,当 condition2
变为 true
时,break
语句将立即终止 while
循环:
while (condition1){
statement1;
if (condition2)
break;
}
或者,可以使用 continue
语句来跳过当前迭代中循环体剩余的部分。在下面的示例中,当 condition2
评估为 true
时,调用 continue
,导致程序到达循环的末尾,跳过 statement2
并继续下一个迭代:
while (condition1){
statement1;
if (condition2)
continue;
statement2;
}
注意
break
和 continue
语句都可以用在 for
和 while
循环中。
try-catch 块
在程序执行过程中,可能会发生异常。我们将这些运行时问题称为 异常,它们代表了程序正常功能之外出现的异常情况对响应。设计能够抵御错误的代码是程序员必须应对的最困难的事情之一。
异常通常在程序遇到无法处理的情况时使用 throw
关键字抛出。这也被称为 抛出异常。
try
关键字后面跟着一个包含可能抛出一个或多个异常的语句的块。这些异常可以通过一个或多个 catch
子句捕获,这些子句按顺序列在 try
块之后。此语法的语法如下:
try {
statement1;
} catch (exception-declaration1) {
statement2;
} catch (exception-declaration2) {
statement3;
}
...
catch
块由 catch
关键字、异常声明和块组成。根据 try
块内部抛出的异常,选择一个 catch
子句并执行相应的块。一旦 catch
块终止,程序将继续执行最后一个 catch
子句之后的语句。
让我们通过以下示例来了解如何使用 try-catch 条件语句处理异常:
#include <iostream>
int main()
{
int x = 10;
try {
std::cout << "Inside try block" << std::endl;
if (x > 0) // True
{
throw x;// Following statement will be skipped
std::cout << "After throw keyword" << std::endl;
}
}
catch (int x ) {
std::cout << "Inside catch block: Exception found" << std::endl;
}
std::cout << "Outside try-catch block" << std::endl;
}
Output:
Inside try block
Inside catch block: Exception found
Outside try-catch block
练习 2:计算特定数字在给定列表中出现的次数
在这个练习中,我们将讨论使用 if
语句和 for
循环来计数我们的魔法数字。在这里,我们将尝试找到所有能被 3 整除的数字,范围从 1 到 30。
提示
要找出一个数字是否能被另一个数字整除,请使用取模 (%) 运算符。
现在,让我们执行以下步骤:
-
导入所有必需的头文件:
#include <iostream>
-
我们需要将一个数字被 3 整除的次数存储在一个计数器中。因此,我们定义并初始化
count
变量为0
:unsigned count = 0;
-
现在,我们将使用一个生成 1 到 30 的值的
for
循环,以便我们可以检查它们是否能被 3 整除:for(unsigned x = 1; x <= 30; x++){ }
-
最后,我们将在
for
循环体中使用if
语句和表达式x%3 == 0
来检查,如果除法余数为0
,则该表达式评估为true
:if (x%3 == 0) { count++; }
-
如果前面的条件返回
true
,则X
变量能被3
整除,我们可以增加计数器。 -
最后,我们可以打印
count
:std::cout << count << std::endl;
附加练习:
-
找出 1 到 100 范围内有多少个数字能被 11 整除
-
打印 1 到 30 范围内所有不能被 3 整除的数字
活动 1:使用 while 循环在 1 到 100 范围内找到 7 的因子
在以下活动中,我们将使用 while
循环并实现之前练习中的概念,以打印 1 到 100 范围内能被 7 整除的数字。
现在,让我们以以下方式使用 while
循环重写之前的代码:
-
创建一个
unsigned
类型的变量。 -
现在,使用
while
循环编写打印能被7
整除的数字的逻辑。 -
然后,在每次迭代后,我们必须增加
i
的值。使用以下代码:i++;
本活动的解决方案可以在第 282 页找到。
数组
数组是一种数据结构,它包含一系列相同类型的元素,这些元素被放置在连续的内存位置中,可以通过它们的位置单独访问。
数组具有固定的大小,不能扩展;这有助于它们的运行时性能,但以有限的灵活性为代价。
数组声明
如同任何其他变量一样,数组在使用之前需要声明。数组声明具有以下形式:
type name [elements];
在这里,type
是包含元素的类型,name
是array
变量的标识符,而elements
是数组的长度,因此它表示包含的元素数量。
术语elements
需要是一个在编译时已知的常量表达式,因为那时会评估数组大小以确定分配的静态内存块的维度。
当声明一个数组时,其内容是不确定的,这意味着元素没有被设置为任何特定的值。这对于程序员来说常常令人困惑,因为你可能会期望元素被初始化为数组类型的默认值。
数组初始化
可以在声明时特别初始化数组元素,将这些初始值括在花括号中:
int foo [5] = { 1, 2, 11, 15, 1989 };
当我们初始化列表数组时,我们也可以省略其长度,因为它将由提供的值的数量确定。以下声明与上一个声明等效:
int foo [] = { 1, 2, 11, 15, 1989 };
如果提供了元素数量,但数组只初始化了较少的元素,那么剩余的值将被零初始化,例如:
int foo [5] = { 1, 2, 11 };
之前的代码等效于以下代码:
int foo [5] = { 1, 2, 11, 0, 0 };
访问数组的值
可以像访问同一类型的任何其他值一样访问数组的值。以下访问数组的语法:
name[index]
可以访问数组元素以存储新元素或读取其值。
例如,以下语句更新了之前声明的名为foo
的数组中位置 4 的值:
foo [4] = 15
以下用于将位置 2 的元素内容复制到一个新变量中:
int x = foo [2]
重要的是要注意,位置4
和2
的元素分别指的是第五个和第三个元素。这是因为索引是从0
开始的。以下图表说明了数组中索引条目的工作方式:
图 1.9:初始化一维数组
超出数组索引的有效范围在语法上是正确的,因此编译器不会产生任何错误。在 C++中访问数组越界被视为未定义行为,这意味着代码的行为不是由语言规范规定的。这可能导致运行时错误,例如由于访问未分配的内存位置而导致的错误,或者由于尝试访问程序不拥有的内存而导致程序终止(段错误)。
多维数组
多维数组通常描述为数组中的数组,其中数组的元素是其他数组。
以下语法说明了二维数组:
type name [n][m];
int bi_array [3][4]
在这里,n
是数组的维度,m
是其元素的维度。
通常,在像之前的二维数组这样的数组中,第一个维度被称为行
,第二个维度被称为列
。
多维数组不仅限于二维;它们可以有需要的任意多个维度,但请注意,随着每个维度的增加,使用的内存呈指数增长。类似于一维数组,多维数组可以通过指定每个行的初始化器列表来初始化。让我们检查以下代码:
#include <iostream>
int main()
{
int foo [3][5] = {{ 1, 2, 11, 15, 1989 }, { 0, 7, 1, 5, 19 }, { 9, 6, 131, 1, 2 }};
for (int x = 0; x < 3; x++)
{
for (int y = 0; y < 5; y++)
{
std::cout <<"Array element at [" << x << "]" << "[" << y << "]: "<< foo[x][y] << std::endl;
}
}
}
Output:
Array element at [0][0]: 1
Array element at [0][1]: 2
Array element at [0][2]: 11
Array element at [0][3]: 15
Array element at [0][4]: 1989
Array element at [1][0]: 0
Array element at [1][1]: 7
Array element at [1][2]: 1
Array element at [1][3]: 5
Array element at [1][4]: 19
Array element at [2][0]: 9
Array element at [2][1]: 6
Array element at [2][2]: 131
Array element at [2][3]: 1
Array element at [2][4]: 2
或者,由于编译器可以从定义中推断内部数组的长度,嵌套的大括号是可选的,仅提供以提高可读性:
int foo [3][5] = {1, 2, 11, 15, 1989, 0, 7, 1, 5, 19, 9, 6, 131, 1, 2};
活动二:定义一个二维数组并初始化其元素
在本节中,我们将定义一个整数类型的二维数组(3x3
)并编写一个程序来为每个元素分配其对应数组索引条目的加和:
-
定义一个大小为
3x3
的整数数组。 -
使用嵌套的
for
循环遍历数组的每个元素,并将乘积值x
和y
分配给索引。注意:
该活动的解决方案可以在第 282 页找到。
摘要
在本章中,我们了解了该语言的基本结构和语法。我们从编译模型的概述开始,该模型是将 C++源代码转换为可执行程序的过程。我们编写、编译并运行了我们的第一个程序,一个简单的main
函数,该函数成功返回了退出/返回代码。
我们描述了该语言提供的内置算术类型。
我们学习了如何声明和定义变量名,以及引用和指针之间的区别。我们还看到了const
限定符的使用及其优势。
此外,我们还讨论了控制流语句以及如何利用它们执行更复杂的行为。
最后,我们介绍了数组和多维数组,以及初始化它们和访问其值所需的操作。在下一章中,我们将学习 C++中的函数是什么,以及为什么我们应该在我们的代码中使用它们。
第三章:第二章
函数
课程目标
到本章结束时,你将能够:
-
解释函数是什么以及如何声明它们
-
利用局部和全局变量
-
向函数传递参数并从函数返回值
-
创建重载函数并适当地调用它们
-
应用命名空间的概念来组织函数
在本章中,我们将探讨 C++中的函数,如何使用它们,以及为什么我们想要使用它们。
简介
函数是程序员工具箱中编写可维护代码的核心工具。函数的概念在几乎每一种编程语言中都很常见。在不同的语言中,函数有不同的名称:过程、例程等等,但它们都有两个共同的主要特征:
-
它们代表了一系列组合在一起的指令。
-
指令序列通过一个名称来标识,该名称可以用来引用函数。
当函数提供的功能需要时,程序员可以调用或调用该函数。
当函数被调用时,执行指令序列。调用者还可以向函数提供一些数据,以便在程序中的操作中使用。以下是使用函数的主要优点:
-
减少重复:通常情况下,程序需要在代码库的不同部分重复相同的操作。函数允许我们编写一个经过仔细测试、文档化且高质量的单一实现。此代码可以从代码库的不同位置调用,这实现了代码的可重用性。这反过来又提高了程序员的效率和软件的质量。
-
提升代码可读性和可修改性:通常,我们需要执行多个操作来实现程序中的功能。在这些情况下,将操作组合在一起放入函数中,并为函数赋予描述性的名称,有助于表达我们想要做什么,而不是我们是如何做的。
使用函数极大地提高了我们代码的可读性,因为现在它由我们试图实现的目标的描述性名称组成,而没有达到结果的噪声。
实际上,测试和调试更容易,因为你可能只需要修改一个函数,而不必重新审视程序的结构。
-
更高的抽象级别:我们可以给函数一个有意义的名称来表示它应该实现什么。这样,调用代码可以关注函数应该做什么,而不需要知道操作是如何执行的。
注意
抽象是从类中提取所有相关属性并暴露它们的过程,同时隐藏对特定用途不重要的细节。
让我们以一棵树为例。如果我们将其应用于果园的情境,我们可以将树抽象为一个“机器”,它占用一定量的空间,并在阳光、水和肥料的条件下,每年产生一定数量的果实。我们感兴趣的是树的生产能力,因此我们希望将其暴露出来,并隐藏所有与我们的案例无关的其他细节。
在计算机科学中,我们希望应用相同的概念:捕获类的关键基本属性,而不显示实现它的算法。
这的一个典型例子是sort
函数,它在许多语言中都存在。我们知道函数期望什么以及它将要做什么,但我们很少意识到用于执行它的算法,而且它可能在语言的不同的实现之间发生变化。
在接下来的章节中,我们将揭示函数声明和定义是如何工作的。
函数声明和定义
函数声明的作用是告诉编译器函数的名称、参数和返回类型。一旦函数被声明,它就可以在程序的其余部分中使用。
函数的定义指定了函数执行的操作。
声明由返回值的类型组成,后跟函数名,然后是一对括号内的参数列表。后两个组件构成了函数的签名。函数声明的语法如下:
// Declaration: function without body
return_type function_name( parameter list );
如果函数不返回任何内容,则可以使用void
类型,如果函数不期望任何参数,则列表可以为空。
让我们来看一个函数声明的例子:
void doNothingForNow();
在这里,我们声明了一个名为doNothingForNow()
的函数,它不接受任何参数也不返回任何内容。在此声明之后,我们可以在程序中调用doNothingForNow()
函数。
要调用没有参数的函数,只需写出函数名后跟一对括号。
当函数被调用时,执行流程会从当前正在执行的函数体转移到被调用函数的体。
在以下示例中,执行流程从main
函数体的开始处开始,并按顺序执行其操作。它遇到的第一个操作是调用doNothingForNow()
。在那个时刻,执行流程进入doNothingForNow()
的体。
当函数内的所有操作都执行完毕,或者函数指示它们返回调用者时,执行流程从函数调用之后的操作恢复。
在我们的例子中,函数调用之后的操作会在控制台上打印Done
:
#include <iostream>
void doNothingForNow();
int main() {
doNothingForNow ();
std::cout << "Done";
}
如果我们编译这个程序,编译将成功,但链接会失败。
在这个程序中,我们指示编译器存在一个名为doNothingForNow()
的函数,然后我们调用了它。编译器生成一个调用doNothingForNow()
的输出。
链接器随后尝试从编译器输出创建一个可执行文件,但由于我们没有定义doNothingForNow()
,它找不到函数的定义,因此失败。
要成功编译程序,我们需要定义doNothingForNow()
。在下一节中,我们将通过相同的示例来探索如何定义一个函数。
定义一个函数
要定义一个函数,我们需要编写与声明相同的信息:返回类型、函数名和参数列表,然后是函数体。函数体定义了一个新的作用域,由花括号分隔的一系列语句组成。
当函数执行时,语句按顺序执行:
// Definition: function with body
return_type function_name( parameter_list ) {
statement1;
statement2;
...
last statement;
}
让我们通过添加doNothingForNow()
的函数体来修复程序:
void doNothingForNow() {
// Do nothing
}
在这里,我们使用空体定义了doNothingForNow()
。这意味着一旦函数执行开始,控制流就会返回到调用它的函数。
注意
当我们定义一个函数时,我们需要确保签名(返回值、名称和参数)与声明相同。
定义也计为声明。如果我们定义函数在调用它之前,我们可以省略声明。
现在我们回顾一下我们的程序,因为我们已经为我们的函数添加了定义:
#include <iostream>
void doNothingForNow() {
// do nothing
}
int main() {
doNothingForNow();
std::cout << "Done";
}
如果我们编译并运行程序,它将成功并在输出控制台显示Done
。
在程序中,可以有多个相同函数的声明,只要声明相同。另一方面,根据单一定义规则(ODR),只能存在一个函数的定义。
注意
如果在不同的文件中编译,相同的函数可能有多个定义,但它们必须相同。如果不相同,则程序可能会执行不可预测的操作。
编译器不会警告你!
解决方案是将声明放在头文件中,将定义放在实现文件中。
一个头文件被包含在许多不同的实现文件中,这些文件中的代码可以调用该函数。
实现文件只编译一次,因此我们可以保证编译器只能看到一次定义。
然后,链接器将编译器的所有输出合并在一起,找到一个函数的定义,并生成一个有效的可执行文件。
练习 3:从 main()中调用函数
在我们的应用程序中,我们想要记录错误。为此,我们必须指定一个名为log()
的函数,当被调用时,它会将Error!
打印到标准输出。
让我们创建一个可以从多个文件调用的函数,并将其放在一个不同的头文件中,该文件可以包含:
-
创建一个名为
log.h
的文件,并声明一个没有参数且不返回任何内容的log()
函数:void log();
-
现在,让我们创建一个新的文件,
log.cpp
,在其中定义log()
函数以打印到标准输出:#include <iostream> // This is where std::cout and std::endl are defined void log() { std::cout << "Error!" << std::endl; }
-
将
main.cpp
文件修改为包含log.h
并在main()
函数中调用log()
:#include <log.h> int main() { log(); }
-
编译这两个文件并运行可执行文件。你会看到当我们执行它时,会打印出错误!信息。
局部和全局变量
函数体是一个可以包含有效语句的代码块,其中之一是变量定义。正如我们在第 1 课“入门”中学到的,当这样的语句出现时,函数声明了一个局部变量。
这与全局变量形成对比,全局变量是在函数(以及我们将在第 3 课“类”中探讨的类)外部声明的变量。
局部变量和全局变量之间的区别在于其声明的作用域,因此,在谁可以访问它。
注意
局部变量在函数作用域内,并且只能被函数访问。相反,全局变量可以被任何可以看到它们的函数访问。
使用局部变量而不是全局变量是可取的,因为它们使封装成为可能:只有函数体内的代码可以访问和修改变量,使变量对程序的其他部分不可见。这使得理解函数如何使用变量变得容易,因为其使用仅限于函数体,我们保证没有其他代码正在访问它。
封装通常有三个单独的原因,我们将在第 3 课“类”中更详细地探讨:
-
为了限制对功能使用的数据的访问
-
为了将数据及其操作它的功能捆绑在一起
-
封装是一个关键概念,它允许你创建抽象
另一方面,全局变量可以被任何函数访问。
这使得在与它们交互时很难确定函数的值,除非我们不仅知道我们的函数做什么,而且知道所有与全局变量交互的程序中的其他代码做什么。
此外,我们后来添加到程序中的代码可能会以我们没有预料到的方式修改全局变量,即使没有修改函数本身,也可能破坏函数的功能。这使得修改、维护和演进程序变得极其困难。
解决这个问题的方法是使用const
限定符,这样就没有代码可以更改变量,我们可以将其视为一个永远不会改变的值。
注意
在可能的情况下,始终使用const
限定符与全局变量一起使用。
尽量避免使用可变全局变量。
使用全局const
变量而不是直接在代码中使用值是一种良好的实践。它们允许你给值赋予一个名称和意义,而不承担可变全局变量带来的任何风险。
与变量对象一起工作
在 C++中,理解变量、对象及其生命周期之间的关系对于正确编写程序至关重要。
注意
对象是程序内存中的一块数据。
变量是我们赋予对象的名称。
在 C++中,变量作用域和它所引用对象的生存期之间有一个区别。变量作用域是程序中变量可以使用的部分。
相反,对象的生存期是在执行期间对象可以访问的时间。
让我们通过以下程序来了解对象的生存期:
#include <iostream>
/* 1 */ const int globalVar = 10;
int* foo(const int* other) {
/* 5 */ int fooLocal = 0;
std::cout << "foo's local: " << fooLocal << std::endl;
std::cout << "main's local: " << *other << std::endl;
/* 6 */ return &fooLocal;
}
int main()
{
/* 2 */ int mainLocal = 15;
/* 3 */ int* fooPointer = foo(&mainLocal);
std::cout << "main's local: " << mainLocal << std::endl;
std::cout << "We should not access the content of fooPointer! It's not valid." << std::endl;
/* 4 */ return 0;
}
图 2.1:对象的生存期
变量的生存期从初始化开始,到包含它的块结束时结束。即使我们有变量或引用的指针,我们也应该只在它仍然有效时访问它。fooPointer
指向一个不再有效的变量,因此不应该使用它!
当我们在函数的作用域内声明一个局部变量时,编译器会在函数执行到达变量声明时自动创建一个对象;变量引用那个对象。
当我们声明一个全局变量时,我们实际上是在一个没有明确持续时间的范围内声明它——它在整个程序运行期间都是有效的。正因为如此,编译器在执行任何函数之前(甚至包括main()
函数)在程序开始时创建对象。
当执行退出变量声明的范围,或者全局变量在程序终止的情况下,编译器也会负责终止对象的生存期。对象的生存期终止通常被称为销毁。
在作用域块中声明的变量,无论是局部还是全局,都称为自动变量,因为编译器负责初始化和终止与变量相关联的对象的生存期。
让我们看看一个局部变量的例子:
void foo() {
int a;
}
在这种情况下,变量a
是一个int
类型的局部变量。当执行到达该语句时,编译器会自动使用所谓的默认初始化来初始化它所引用的对象,并且对象将在函数结束时被销毁,同样也是自动的。
注意
对于基本类型,如整数,默认初始化对我们来说不做任何事情。这意味着变量a
将具有一个未指定的值。
如果定义了多个局部变量,对象的初始化顺序是按照声明的顺序进行的:
void foo() {
int a;
int b;
}
变量a
在b
之前初始化。由于变量b
是在a
之后初始化的,所以它的对象在a
所引用的对象之前被销毁。
如果执行从未到达声明处,变量将不会被初始化。如果变量没有被初始化,它也不会被销毁:
void foo() {
if (false) {
int a;
}
int b;
}
在这里,变量 a
永远没有被默认初始化,因此永远不会销毁。这同样适用于全局变量:
const int a = 1;
void main() {
std::cout << "a=" << a << std::endl;
}
变量 a
在调用 main()
函数之前初始化,并在我们从 main()
函数返回值之后销毁。
练习 4:在斐波那契数列中使用局部和全局变量
我们想要编写一个函数,该函数返回斐波那契数列中的第 10 个数。
注意
第 n 个斐波那契数定义为第 n-1 个和第 n-2 个的和,序列中的第一个数是 0,第二个数是 1。
示例:
第 10 个斐波那契数 = 第 8 个斐波那契数 + 第 9 个斐波那契数
我们希望使用最佳实践为值命名并赋予其意义,因此我们不会在代码中使用 10,而是将定义一个名为 POSITION
的全局 const
变量。
我们还将在函数中使用两个局部变量来记住 n-1th
和 n-2th
的数字:
-
编写程序并在头文件之后包含以下全局常量变量:
#include <iostream> const int POSITION = 10; const int ALREADY_COMPUTED = 3;
-
现在,创建一个名为
print_tenth_fibonacci()
的函数,其返回类型为void
:void print_tenth_fibonacci()
-
在函数内部,包含三个局部变量,分别命名为
n_1
、n_2
和current
,类型为int
,如下所示:int n_1 = 1; int n_2 = 0; int current = n_1 + n_2;
-
让我们创建一个
for
循环,使用我们之前定义的全局变量作为起始和结束索引,生成剩余的斐波那契数,直到达到第 10 个:for(int i = ALREADY_COMPUTED; i < POSITION; ++i){ n_2 = n_1; n_1 = current; current = n_1 + n_2; }
-
现在,在之前的
for
循环之后,添加以下打印语句以打印存储在current
变量中的最后一个值:std::cout << current << std::endl;
-
在
main()
函数中,调用print_tenth_fibonacci()
并打印斐波那契数列的第 10 个元素的值:int main() { std::cout << "Computing the 10th Fibonacci number" << std::endl; print_tenth_fibonacci(); }
让我们了解这个练习的变量数据流。首先,初始化 n_1
变量,然后初始化 n_2
,紧接着初始化 current
。然后,销毁 current
,销毁 n_2
,最后销毁 n_1
。
i
也是由 for
循环创建的作用域中的自动变量,因此它在 for
循环作用域结束时销毁。
对于 cond1
和 cond2
的每个组合,确定以下程序中初始化和销毁发生的时间:
void foo()
if(cond1) {
int a;
}
if (cond2) {
int b;
}
}
传递参数和返回值
在 简介 部分,我们提到调用者可以向函数提供一些数据。这是通过将参数传递给函数的参数来完成的。
函数接受的参数是其签名的一部分,因此我们需要在每个声明中指定它们。
函数可以接受的参数列表包含在函数名称后面的括号中。函数括号中的参数由逗号分隔,由类型组成,并可选地有一个标识符。
例如,一个接受两个整数的函数声明如下:
void two_ints(int, int);
如果我们想要给这些参数命名,a
和 b
分别,我们会写如下:
void two_ints(int a, int b);
在其主体内部,函数可以像声明变量一样访问函数签名中定义的标识符。函数参数的值是在函数调用时确定的。
要调用一个接受参数的函数,你需要写出函数名,然后在一对括号内写上一系列表达式:
two_ints(1,2);
这里,我们用两个参数1
和2
调用了two_ints
函数。
调用函数时使用的参数初始化函数期望的参数。在two_ints
函数中,变量a
将等于1
,而b
将等于2
。
每次函数被调用时,都会从用于调用函数的参数中初始化一组新的参数。
注意
参数:这是一个由函数定义的变量,可以根据代码提供数据。
参数:调用者想要绑定到函数参数的值。
在以下示例中,我们使用了两个值,但也可以使用任意表达式作为参数:
two_ints(1+2, 2+3);
注意
表达式评估的顺序没有指定!
这意味着在调用two_ints(1+2, 2+3);
时,编译器可能会首先执行1+2
然后执行2+3
,或者先执行2+3
然后执行1+2
。如果表达式没有改变程序中的任何状态,这通常不是问题,但如果它确实创建了难以检测的 bug,则可能成为问题。例如,给定int i = 0;
,如果我们调用two_ints(i++, i++)
,我们不知道函数将被调用为two_ints(0, 1)
还是two_ints(1, 0)
。
通常,最好在它们自己的语句中声明会改变程序状态的表达式,并使用不修改程序状态的表达式调用函数。
函数参数可以是任何类型。正如我们之前看到的,C++中的类型可以是值、引用或指针。这给程序员提供了一些选择,可以根据期望的行为从调用者那里接受参数。
在以下子节中,我们将更详细地探讨按值传递和按引用传递的工作机制。
按值传递
当函数的参数类型是值类型时,我们说函数是通过值传递参数,或者参数是通过值传递的。
当参数是值类型时,每次函数被调用时都会创建一个新的局部对象。
正如我们通过自动变量看到的,对象的生存期一直持续到执行未达到函数的作用域的末尾。
当参数被初始化时,会从调用函数时提供的参数中创建一个新的副本。
注意
如果你想要修改一个参数,但又不想或不在乎调用代码看到修改,请使用按值传递。
练习 5:使用按值传递参数计算年龄
詹姆斯想编写一个 C++程序,通过提供当前年龄作为输入来计算一个人的五年后年龄。
要实现这样一个程序,他打算编写一个函数,该函数通过值接收一个人的年龄并计算他们将在 5 年后多大,然后将其打印到屏幕上:
-
创建一个名为
byvalue_age_in_5_years
的函数,如图所示。确保调用代码中的值不发生变化:void byvalue_age_in_5_years(int age) { age += 5; std::cout << "Age in 5 years: " << age << std::endl; // Prints 100 }
-
现在,在
main()
中,通过传递变量age
作为值来调用我们在上一步创建的函数:int main() { int age = 95; byvalue_age_in_5_years(age); std::cout << "Current age: " << age; // Prints 95 }
注意
通过值传递应该是接受参数的默认方式:除非你有特定的理由不这样做,否则始终使用它。
原因在于这使调用代码和被调用函数之间的分离更加严格:调用代码无法看到被调用函数对参数所做的更改。
通过值传递参数在调用函数和被调用函数之间创建了一个清晰的边界,因为参数是被复制的:
-
作为调用函数,我们知道我们传递给函数的变量不会被它修改。
-
作为被调用函数,我们知道即使我们修改了提供的参数,也不会对被调用函数产生影响。
这使得代码更容易理解,因为我们对参数所做的更改不会在函数外部产生影响。
当传递参数时,通过值传递可能是更快的选项,特别是如果参数的内存大小较小(例如,整数、字符、浮点数或小型结构)。
我们需要记住的是,通过值传递会对参数执行复制操作。有时,这可能在内存和处理时间上都可能是一个昂贵的操作,例如在复制包含许多元素的容器时。
有一些情况下,可以通过 C++11 中添加的move
语义来克服这种限制。我们将在第 3 课,类中看到更多关于它的内容。
让我们看看一种不同于通过值传递的替代方法,它具有不同的属性集。
引用传递
当函数的参数类型是引用类型时,我们说函数是通过引用接受参数或参数是通过引用传递的。
我们之前看到,引用类型不会创建一个新的对象——它只是一个新的变量,或名称,它指向已经存在的对象。
当通过引用接受参数的函数被调用时,引用绑定到用于参数的对象:参数将引用给定的对象。这意味着函数可以访问调用代码提供的对象并对其进行修改。
如果函数的目标是修改一个对象,这很方便,但在这种情况下,理解调用者和被调用函数之间的交互可能更困难。
注意
除非函数必须修改变量,否则始终使用const
引用,正如我们稍后将会看到的。
练习 6:使用引用传递计算年龄增加
詹姆斯想编写一个 C++程序,给定任何人的年龄作为输入,如果他们的年龄在接下来的 5 年内将达到 18 岁或以上,则打印Congratulations!
。
让我们编写一个接受引用参数的函数:
-
创建一个名为
byreference_age_in_5_years()
的void
类型函数,如图所示:void byreference_age_in_5_years(int& age) { age += 5; }
-
现在,在
main()
中,通过将变量age
作为引用传递来调用我们在上一步创建的函数:int main() { int age = 13; byreference_age_in_5_years(age); if (age >= 18) { std::cout << "Congratulations! " << std::endl; } }
与按值传递不同,按引用传递的速度不会随着传递的对象的内存大小而改变。
这使得在复制对象时,通过引用传递成为首选方法,因为将值传递给函数是昂贵的,尤其是如果我们不能使用在 C++11 中添加的move
语义。
注意
如果你想要使用引用传递,但又没有修改提供的对象,请确保使用const
。
使用 C++,我们可以使用std::cin
从正在执行的程序的控制台读取输入。
当编写std::cin >> variable;
时,程序将阻塞等待用户输入,然后只要它是一个有效的值并且程序知道如何读取它,它就会将读取的输入值填充到variable
中。默认情况下,我们可以分配所有内置数据类型以及标准库中定义的一些类型,如string
。
活动三:检查投票资格
詹姆斯正在创建一个程序,在用户输入当前年龄后,在控制台屏幕上打印消息:*Congratulations! You are eligible to vote in your country*
或*No worries, just <value> more years to go.*
。
-
创建一个名为
byreference_age_in_5_years(int& age)
的函数,并添加以下代码:#include <iostream> void byreference_age_in_5_years(int& age) { if (age >= 18) { std::cout << "Congratulations! You are eligible to vote for your nation." << std::endl; return;
-
在
else
块中,添加计算他们可以投票剩余年数的代码:} else{ int reqAge = 18; } }
-
在
main()
中,添加如图所示的输入流,以接受用户输入。在上一个函数中将值作为引用传递:int main() { int age; std::cout << "Please enter your age:"; std::cin >> age;
这个活动的解决方案可以在第 284 页找到。
使用 const 引用或 r-value 引用
临时对象不能作为引用参数的参数传递。为了接受临时参数,我们需要使用const
引用或r-value引用。r-value 引用是由两个井号&&
标识的引用,只能引用临时值。我们将在第 4 课,泛型编程和模板中更详细地了解它们。
我们需要记住,指针是一个表示对象位置的值。
作为值,这意味着当我们接受参数作为指针时,指针本身作为值传递。
这意味着函数内部指针的修改将不会对调用者可见。
但如果我们正在修改指针指向的对象,那么原始对象将被修改:
void modify_pointer(int* pointer) {
*pointer = 1;
pointer = 0;
}
int main() {
int a = 0;
int* ptr = &a;
modify_pointer(ptr);
std::cout << "Value: " << *ptr << std::endl;
std::cout << "Did the pointer change? " << std::boolalpha << (ptr == &a);
}
大多数时候,我们可以将传递指针视为传递引用,前提是你需要意识到指针可能为空。
将参数作为指针接受主要用于三个原因:
-
通过提供起始指针和结束指针或数组的大小来遍历数组的元素。
-
可选地修改一个值。这意味着如果提供了值,函数将修改该值。
-
返回多个值。这通常是为了设置作为参数传递的指针的值,然后返回一个错误代码来指示操作是否执行。
我们将在第 4 课,泛型编程和模板中看到,C++11 和 C++17 中引入的特性如何使我们能够避免在某些用例中使用指针,消除一些常见错误的可能性,例如取消引用无效指针或访问未分配的内存。
按值传递或按引用传递的选项适用于函数期望的每个参数,独立适用。
这意味着函数可以按值传递一些参数,按引用传递一些参数。
从函数返回值
到目前为止,我们已经看到了如何向函数提供值。在本节中,我们将看到函数如何向调用者返回值。
我们之前提到,函数声明的第一部分是函数返回的类型:这通常被称为函数的返回类型。
所有的前一个示例都使用了void
来表示它们没有返回任何内容。现在,是时候看看一个返回值的函数的例子了:
int sum(int, int);
前一个函数通过值接受两个整数作为参数,并返回一个整数。
调用者代码中函数的调用是一个求值为整数的表达式。这意味着我们可以在允许表达式的任何地方使用它:
int a = sum(1, 2);
函数可以通过使用return
关键字并跟随后要返回的值来返回一个值。
函数可以在其体内多次使用return
关键字,每次执行到达return
关键字时,程序将停止执行函数并返回到调用者,如果有的话,返回函数的值。让我们看看以下代码:
void rideRollercoasterWithChecks(int heightInCm) {
if (heightInCm < 100) {
std::cout << "Too short";
return;
}
if (heightInCm > 210) {
std::cout << "Too tall";
return;
}
rideRollercoaster();
// implicit return at the end of the function
}
如果函数到达其体的末尾,它也会返回给调用者。
这就是我们之前在示例中所做的,因为我们没有使用return
关键字。
如果函数具有void
返回类型,则不显式返回可能是可以的。然而,如果函数预期要返回一个值,它将给出意外的结果:返回的类型将具有一个未指定的值,程序将不会正确。
一定要启用这个警告,因为它会为你节省大量的调试时间。
注意
这很令人惊讶,但每个主要的编译器都允许编译声明了除void
以外的返回类型的函数,但并没有返回值。
在简单的函数中很容易发现这一点,但在具有许多分支的复杂函数中则要困难得多。
每个编译器都支持选项来警告你如果函数没有提供值就返回。
让我们来看一个返回整数的函数示例:
int sum(int a, int b) {
return a + b;
}
正如我们之前所说的,一个函数可以在其体内多次使用return
语句,如下面的示例所示:
int max(int a, int b) {
if(a > b) {
return a;
} else {
return b;
}
}
我们总是返回一个与参数值无关的值。
注意
在算法中尽早返回是一个好的实践。
原因在于,当你遵循代码的逻辑时,尤其是在有许多条件的情况下,一个return
语句告诉你哪个执行路径已经完成,这允许你忽略函数剩余部分发生的事情。
如果你只在函数的末尾返回,你总是必须查看函数的完整代码。
由于函数可以声明为返回任何类型,我们必须决定是返回值还是引用。
按值返回
返回类型为值类型的函数被称为按值返回。
当一个按值返回的函数到达return
语句时,程序会创建一个新的对象,该对象从return
语句中的表达式值初始化。
在前面的函数sum
中,当代码达到返回a + b
的阶段时,会创建一个新的整数,其值等于a
和b
的和,然后返回。
在调用者的方面,int a = sum(1,2);
,会创建一个新的临时自动对象,并从函数返回的值(从a
和b
的和创建的整数)初始化。
这个对象被称为临时对象,因为它的生存期仅在创建它的完整表达式执行期间有效。我们将在按引用返回部分看到这意味着什么以及为什么这很重要。
调用代码然后可以使用返回的临时值在另一个表达式中使用,或者将其赋值给一个值。
在完整表达式的末尾添加,因为临时对象的生存期已经结束,它将被销毁。
在这个解释中,我们提到在返回值时对象会被初始化多次。这不是一个性能问题,因为 C++允许编译器优化所有这些初始化,并且初始化通常只发生一次。
注意
按值返回更可取,因为它通常更容易理解,更容易使用,并且与按引用返回一样快。
按值返回为什么这么快?C++11 引入了move
语义,允许在支持move
操作时移动而不是复制返回类型。我们将在第 3 课,类中看到这一点。甚至在 C++11 之前,所有主流编译器都实现了返回值优化(RVO)和命名返回值优化(NRVO),其中函数的返回值直接在它们返回时会被复制的变量中构造。在 C++17 中,这种优化也被称为复制省略,成为强制要求。
按引用返回
返回类型为引用的函数被称为按引用返回。
当返回引用的函数到达一个return
语句时,一个新的引用将从return
语句中使用的表达式初始化。
在调用者中,函数调用表达式被替换为返回的引用。
然而,在这种情况下,我们还需要注意所引用的对象的生存期。让我们看一个例子:
const int& max(const int& a, const int& b) {
if (a > b) {
return a;
} else {
return b;
}
}
首先,我们需要注意这个函数已经有一个警告。max
函数是通过值返回的,当它们相等时,返回a
或b
没有区别。
在这个函数中,相反,当a == b
时,我们返回b
,这意味着调用此函数的代码需要意识到这种区别。在函数返回非const
引用时,它可能会修改由返回引用所引用的对象,而返回a
或b
可能会有所不同。
我们已经看到了引用如何使我们的代码更难以理解。
让我们看看我们使用的函数:
int main() {
const int& a = max(1,2);
std::cout << a;
}
这个程序有错误!原因是1
和2
是临时值,正如我们之前解释的,临时值的生命周期直到包含它的完整表达式结束。
为了更好地理解“包含它的完整表达式的结束”的含义,让我们看看前面代码块中的代码:int& a = max(1,2);
。这段代码中有四个表达式:
-
1
是一个整数字面量,它仍然算作一个表达式。 -
2
是一个整数字面量,类似于1
。 -
max(expression1, expression2)
是一个函数调用表达式。 -
a = expression3
是一个赋值表达式。
所有这些都在变量a
的声明语句中发生。
第三点涵盖了函数调用表达式,而包含完整表达式的内容将在下一点中介绍。
这意味着生命周期1
和2
将在赋值结束时停止。但我们得到了其中一个的引用!并且我们正在使用它!
C++禁止访问生命周期已结束的对象,这将导致程序无效。
在更复杂的例子中,例如int a = max(1,2) + max(3,4);
,max
函数返回的临时对象将有效直到赋值结束,但之后不再有效。
在这里,我们使用两个引用来求和,然后将结果作为值赋值。如果我们像以下示例中那样将结果赋给一个引用,即int& a = max(1,2) + max(3,4);
,程序就会出错。
这听起来很令人困惑,但理解这一点很重要,因为它可能是我们在创建它的完整表达式执行完毕后使用临时对象时难以调试的问题的来源。
让我们看看函数返回引用时常见的另一个错误:
int& sum(int a, int b) {
int c = a + b;
return c;
}
我们在函数体内创建了一个局部自动对象,然后返回了对它的引用。
在上一节中,我们了解到局部对象的生存期在函数结束时结束。这意味着我们正在返回一个引用到对象的引用,其生存期将始终被终止。
之前,我们提到了按引用传递参数和按指针传递参数之间的相似性。
当返回指针时,这种相似性仍然存在:指针指向的对象在指针稍后解引用时必须存在。
到目前为止,我们已经涵盖了按引用返回时的错误示例。如何正确地将引用用作函数的返回类型?
正确使用引用作为返回值的重要部分是确保对象比引用存在的时间更长:对象必须始终存活——至少直到有引用指向它。
一个常见的例子是访问对象的一部分,例如,使用 std::array
,与内置数组相比,这是一个更安全的选项:
int& getMaxIndex(std::array<int, 3>& array, int index1, int index2) {
/* This function requires that index1 and index2 must be smaller than 3! */
int maxIndex = max(index1, index2);
return array[maxIndex];
调用代码如下:
int main() {
std:array<int, 3> array = {1,2,3};
int& elem = getMaxIndex(array, 0, 2);
elem = 0;
std::cout << array[2];
// Prints 0
}
在这个例子中,我们正在返回数组内部元素的引用,而数组比引用存在的时间更长。
以下是一些正确使用按引用返回的指南:
-
永远不要返回局部变量(或其一部分)的引用。
-
永远不要返回一个按值接收的参数(或其一部分)的引用。
当返回作为参数接收的引用时,传递给函数的参数必须比返回的引用存活的时间更长。
即使返回对象的一部分(例如,数组的一个元素)的引用,也要应用之前的规则。
活动 4:使用按引用传递和按值传递
在这个活动中,我们将看到在编写函数时,根据函数接受的参数可以做出不同的权衡:
-
编写一个函数,该函数接受两个数字并返回它们的和。它应该通过值还是引用接收参数?它应该按值还是按引用返回?
-
之后,编写一个函数,该函数接受两个包含十个整数的
std::arrays
和一个索引(保证小于 10),并将两个数组中给定索引处的较大元素返回。 -
调用函数应该随后修改该元素。它应该通过值还是引用接收参数?它应该按值还是按引用返回?如果值相同会发生什么?
通过引用接收数组并按引用返回,因为我们正在说调用函数应该修改元素。由于没有理由使用引用,所以按值接收索引。
如果值相同,则返回第一个数组中的元素。
注意
这个活动的解决方案可以在第 285 页找到。
常量参数和默认参数
在上一章中,我们看到了如何在函数参数和返回类型中使用引用,以及何时使用引用。C++还有一个额外的限定符,即 const
限定符,它可以独立于类型的 ref-ness(类型是否为引用)使用。
让我们看看在查看函数如何接受参数时,我们调查的各种场景中const
是如何使用的。
通过常量值传递
在按值传递时,函数参数是一个值类型:当调用时,参数会复制参数。
这意味着无论const
是否在参数中使用,调用代码都无法看到区别。
在函数签名中使用const
的唯一原因是为了向实现者说明它不能修改这样的值。
这通常不是常见的做法,因为函数签名最大的价值在于让调用者理解调用函数的约定。正因为如此,即使函数不修改参数,也很少看到int max(const int, const int)
这样的用法。
虽然有一个例外:当函数接受一个pointer
时。
在这种情况下,函数想要确保它没有将新值赋给指针。在这里,指针的行为类似于引用,因为它不能绑定到新对象,但提供了空值。
一个例子可以是setValue(int * const)
,这是一个接受const
指针到int
的函数。
整数不是const
,因此它可以被改变,但指针是const
的,实现者不能在实现过程中改变它。
通过常量引用传递
在通过引用传递时,const
非常重要,每次你在函数的参数中使用引用时,都应该给它添加const
(如果函数不是设计用来修改它的)。
原因在于引用允许你自由地修改提供的对象。
这是有风险的,因为函数可能会错误地修改调用者不期望修改的对象,而且由于调用者和函数之间没有明确的边界,这也很难以理解。
const
而不是解决这个问题,因为函数不能通过const
引用修改对象。
这允许函数使用引用参数,而不必承受使用引用的一些缺点。
函数应该从引用中移除const
,但仅当它打算修改提供的对象时,否则每个引用都应该const
。
const
引用参数的另一个优点是,临时对象可以用作它们的参数。
通过常量值返回
没有广泛的原因通过常量值返回,因为调用代码通常将值赋给一个变量,在这种情况下,变量的const
属性将是决定因素,或者将值传递给下一个表达式,而表达式期望const
值的情况很少。
通过const
值返回也抑制了 C++11 的move
语义,从而降低了性能。
通过常量引用返回
函数应该始终通过const
引用返回,当返回的引用仅用于读取而不被调用代码修改时。
我们应用于返回对象引用时对象生命周期的相同概念也适用于const
:
-
当返回作为参数接受的引用时,如果参数是
const
引用,则返回的引用也必须是const
。 -
当返回作为
const
引用参数接受的对象的一部分的引用时,返回的引用也必须是const
。
如果调用者不期望修改它,则应将作为引用接受的参数返回为const
引用。
有时,编译会失败,指出代码正在尝试修改一个const
引用的对象。除非函数的目的是修改对象,否则解决方案不是从参数中的引用中移除const
,而是寻找为什么你试图执行的操作不与const
一起工作,以及可能的替代方案。
const
不是关于实现的,而是关于函数的意义。
当你编写函数签名时,你应该决定是否使用const
,因为实现将必须找到一种方法来尊重这一点。
例如:
void setTheThirdItem(std::array<int, 10>& array, int item)
这应该清楚地引用数组,因为它的目的是修改数组。
另一方面,我们可以使用以下方法:
int findFirstGreaterThan(const std::array<int, 10>& array, int threshold)
这告诉我们我们只是在查看数组——我们并没有改变它,所以我们应该使用const
。
注意
尽可能使用const
是一个最佳实践,因为它允许编译器确保我们不会修改我们不希望修改的对象。
这可以帮助防止错误。
这也有助于记住另一个最佳实践:永远不要使用同一个变量来表示不同的概念。由于变量不能被改变,因此重新使用它而不是创建一个新的变量不太自然。
默认参数
C++提供的一个功能,使调用者在调用函数时更容易,是默认参数。
默认参数被添加到函数声明中。语法是在参数标识符之后添加一个=
符号并提供默认参数的值。一个例子将是:
int multiply(int multiplied, int multiplier = 1);
函数的调用者可以带有1
或2
个参数调用multiply
:
multiply(10); // Returns 10
multiply(10, 2); // Returns 20
如果省略了具有默认值的参数,则函数将使用默认值。如果调用者大多数情况下都不想修改具有合理默认值的函数,除非在特定情况下,这非常方便。
想象一个返回字符串第一个单词的函数:
char const * firstWord(char const * string, char separator = ' ').
大多数情况下,一个单词由空白字符分隔,但函数可以决定是否使用不同的分隔符。函数提供提供分隔符的可能性并不是强迫大多数调用者,他们只想使用空格,来指定它。
在函数签名声明中设置默认参数是一种最佳实践,而不是在定义中声明它们。
命名空间
函数的一个目标是为了更好地组织我们的代码。要做到这一点,给它们赋予有意义的名称是很重要的。
例如,在包管理软件中,可能会有一个名为 sort
的函数用于排序包。正如你所看到的,名称与用于排序数字列表的函数名称相同。
C++ 有一个功能可以帮助你避免这类问题并将名称分组:命名空间。
命名空间开始了一个作用域,其中声明的所有名称都是命名空间的一部分。
要创建一个命名空间,我们使用 namespace
关键字,后跟标识符,然后是代码块:
namespace example_namespace {
// code goes here
}
要访问命名空间中的标识符,我们在函数名称前加上命名空间名称。
命名空间也可以嵌套。只需在命名空间内使用之前的相同声明即可:
namespace parent {
namespace child {
// code goes here
}
}
要访问命名空间中的标识符,你需要在标识符名称前加上声明它的命名空间名称,然后跟 ::
。
你可能已经注意到,在我们使用 std::cout
之前。这是因为 C++ 标准库定义了 std
命名空间,我们正在访问名为 cout
的变量。
要访问多个命名空间中的标识符,可以在所有命名空间列表前加上 ::
分隔符 – parent::child::some_identifier
。我们可以通过在名称前加上 ::
来访问全局作用域中的名称—::name_in_global_scope
。
如果我们只使用 cout
,编译器会告诉我们该名称在当前作用域中不存在。
这是因为编译器默认只搜索当前命名空间及其父命名空间来查找标识符,所以除非我们指定 std
命名空间,否则编译器不会在其中搜索。
C++ 通过 using
声明帮助使这一过程更加便捷。
using
声明由 using
关键字定义,后跟一个指定了其命名空间的标识符。
例如,using std::cout;
是一个 using
声明,表示我们想要使用 cout
。当我们想要使用一个命名空间中的所有声明时,我们可以写 using namespace namespace_name;
。例如,如果我们想要使用 std
命名空间中定义的所有名称,我们可以写:using namespace std;
。
当在 using
声明中声明一个名称时,编译器也会在查找标识符时查找该名称。
这意味着在我们的代码中,我们可以使用 cout
,编译器会找到 std::cout
。
using
声明只要在声明的范围内就是有效的。
注意
为了更好地组织代码并避免命名冲突,你应该始终将你的代码放在一个特定于你的应用程序或库的命名空间内。
命名空间还可以用来指定某些代码仅由当前代码使用。
让我们想象你有一个名为a.cpp
的文件,其中包含int default_name = 0;
,还有一个名为b.cpp
的文件,其中包含int default_name = 1;
。当你编译这两个文件并将它们链接在一起时,我们得到一个无效程序:同一个变量被声明为两个不同的值,这违反了单一定义规则(ODR)。
但你从未打算让这些变量相同。对你来说,它们只是你想要在.cpp
文件内部使用的变量。
为了让编译器知道这一点,你可以使用匿名命名空间:一个没有标识符的命名空间。
在其中创建的所有标识符都将属于当前翻译单元(通常是.cpp
文件)的私有。
如何访问匿名命名空间内的标识符?你可以直接访问标识符,无需使用命名空间名称,因为匿名命名空间没有标识符,也不需要使用using
声明。
注意
你应该只在使用匿名命名空间的情况下在.cpp
文件中编写代码。
活动五:在命名空间中组织函数
编写一个函数,根据数值输入在命名空间中读取汽车名称用于抽奖:如果用户输入1
,他们将赢得一辆兰博基尼;如果用户输入2
,他们将赢得一辆保时捷:
-
将第一个命名空间定义为
LamborghiniCar
,并定义一个output()
函数,当被调用时将打印"Congratulations! You deserve the Lamborghini.
"。 -
将第二个命名空间定义为
PorscheCar
,并定义一个output()
函数,当被调用时将打印"Congratulations! You deserve the Porsche.
"。 -
编写一个
main
函数,将数字1
和2
的输入读取到名为magicNumber
的变量中。 -
创建一个
if-else
循环,如果输入是1
,则调用第一个命名空间LamborghiniCar::output()
。否则,当输入是2
时,类似地调用第二个命名空间。 -
如果这些条件都不满足,我们打印一条消息,要求他们输入一个介于
1
和2
之间的数字。注意
本活动的解决方案可以在第 285 页找到。
函数重载
我们看到了 C++如何允许我们编写一个函数,该函数可以通过值或引用传递参数,使用const
,并在命名空间中组织它们。
C++有一个额外的强大功能,允许我们给在执行相同概念操作的不同类型上的函数赋予相同的名称:函数重载。
函数重载是指能够声明具有相同名称的多个函数——也就是说,如果它们接受的参数集不同。
这里的一个例子是multiply
函数。我们可以想象这个函数被定义为整数和浮点数,甚至向量矩阵。
如果函数所代表的概念相同,我们可以提供接受不同类型参数的多个函数。
当调用一个函数时,编译器会查看所有具有该名称的函数,称为重载集,并选择与提供的参数最匹配的函数。
函数选择的确切规则很复杂,但行为通常是直观的:编译器会在参数和函数的预期参数之间寻找更好的匹配。如果我们有两个函数,int increment(int)
和 float increment(float)
,并且我们用 increment(1)
调用它们,整数重载会被选择,因为整数与整数相比,比浮点数更匹配,即使整数可以被转换为浮点数。这个例子可以是:
bool isSafeHeightForRollercoaster(int heightInCm) {
return heightInCm > 100 && heightInCm < 210;
}
bool isSafeHeightForRollercoaster(float heightInM) {
return heightInM > 1.0f && heightInM < 2.1f;
}
// Calls the int overload
isSafeHeightForRollercoaster(187);
// Class the float overload
isSafeHeightForRollercoaster(1.67f);
多亏了这个特性,调用代码不需要担心编译器将要选择哪个函数重载,而且由于使用了相同的函数来表达相同的意思,代码可以更加清晰。
活动 6:为 3D 游戏编写数学库
约翰尼想为他正在制作的视频游戏实现一个 数学 库。这将是一个 3D 游戏,因此他需要操作代表三个坐标的点:x、y 和 z。
点被表示为 std::array<float, 3>
。库将在整个游戏中使用,因此约翰尼需要确保它可以在多次包含时工作(通过创建头文件并在那里声明函数)。
该库需要支持以下步骤:
-
查找两个浮点数、两个整数或两个点之间的距离。
-
如果只提供了 2 个点中的一个,则假定另一个点是原点(位置在
(0,0,0)
的点)。 -
此外,约翰尼经常需要从圆的半径(定义为
2*pi*r
)计算圆的周长,以了解敌人能看到多远。pi
在整个程序运行期间是常数(可以在.cpp
文件中全局声明)。 -
当敌人移动时,它会访问几个点。约翰尼需要计算沿着这些点行走的总距离。
-
为了简单起见,我们将点的数量限制为
10
,但约翰尼可能需要多达100
。函数将接受std::array<std::array<float, 3>, 10>
并计算连续点之间的距离。例如(包含 5 个点的列表):对于数组
{{0,0,0}, {1,0,0}, {1,1,0}, {0,1,0}, {0,1,1}}
,总距离是 5,因为从{0,0,0}
到{1,0,0}
的距离是1
,然后从{1,0,0}
到{1,1,0}
的距离又是1
,以此类推,对于剩余的 3 个点也是如此。注意
本活动的解决方案可以在第 286 页找到。
确保函数组织良好,通过将它们分组在一起。
记住,两点之间的距离是通过计算 x2-x1)² + (y2-y1)² + (z2-z1)²
的平方根来得到的。
C++ 提供了 std::pow
函数用于 std::sqrt
函数,它接受要平方的数字。这两个函数都在 cmath
头文件中。
摘要
在本章中,我们看到了 C++提供的用于实现函数的强大功能。
我们首先讨论了为什么函数是有用的以及它们可以用来做什么,然后我们深入探讨了如何声明和定义它们。
我们分析了接受参数和返回值的多种方式,如何利用局部变量,然后探讨了如何通过使用const
和默认参数来提高调用它们的安全性和便利性。
最后,我们看到了函数如何被组织在命名空间中,以及给实现相同概念的不同函数赋予相同名称的能力,这使得调用代码不必考虑调用哪个版本。
在下一章中,我们将探讨如何创建类以及它们在 C++中如何被用来使构建复杂程序变得简单和安全。
第四章:第三章
类
课程目标
在本章结束时,你将能够:
-
声明和定义一个类
-
使用对象访问类的成员
-
应用访问修饰符以封装数据
-
在数据成员和成员函数上使用静态修饰符
-
实现嵌套类
-
使用友元指定符访问私有和受保护的成员
-
使用构造函数、拷贝构造函数、赋值运算符和析构函数
-
重载运算符
-
实现函数对象
在本章中,我们将学习关于 C++ 中的类和对象。
简介
在上一章中,我们看到了如何使用函数将基本操作组合成具有明确意义的单元。此外,在第一章中,我们看到了在 C++ 中,我们可以将数据存储在基本类型中,例如整数、字符和浮点数。
在本章中,我们将介绍如何定义和声明类以及如何访问类的成员函数。我们将探讨 成员
和 友元
函数是什么以及如何在程序中使用它们。在本章的后面部分,我们将探讨构造函数和析构函数的工作原理。在本章结束时,我们将探讨函数对象(functors)以及如何在程序中使用它们。
声明和定义一个类
类是将数据和操作组合在一起以创建新类型的一种方式,这些类型可以用来表示复杂的概念。
基本类型可以组合成更有意义的抽象。例如,位置数据由纬度和经度坐标组成,这些坐标以 float
值表示。在这种表示下,当我们的代码需要操作位置时,我们必须提供纬度和经度作为单独的变量。这很容易出错,因为我们可能会忘记传递两个变量中的一个,或者我们可能会以错误的顺序提供它们。
此外,计算两个坐标之间的距离是一个复杂任务,我们不希望一遍又一遍地编写相同的代码。当我们使用更复杂对象时,这变得更加困难。
继续我们的坐标示例,我们不是使用两个 float
类型的操作,而是可以定义一个类型,该类型存储位置并提供与它交互所需的必要操作。
使用类的优势
类提供了多个好处,例如抽象、信息隐藏和封装。让我们深入探讨这些内容:
-
float
变量,但这并不代表我们想要使用的概念。程序员需要记住这两个变量具有不同的含义,并且应该一起使用。类允许我们明确地定义一个概念,该概念由数据和该数据上的操作组成,并给它赋予一个 名称。在我们的示例中,我们可以创建一个类来表示 GPS 坐标。数据将是两个
float
变量,用于描述表示它的float
变量。 -
信息隐藏:向类的用户公开一组功能,同时隐藏它们在类中实现细节的过程。
这种方法减少了与类交互的复杂性,并使得将来更新类实现变得更加容易:
图 2.1:类直接暴露了用户代码使用的功能,隐藏了它实际上是用两个浮点数实现的这一事实
我们讨论了可以将 GPS 坐标表示为纬度和经度的事实。稍后,我们可能会决定将坐标表示为从北极的距离。多亏了信息隐藏,我们可以改变类的实现方式,而类的用户不会受到影响,因为我们没有改变类提供的功能:
图 2.2:类的实现方式发生变化,但由于它对用户隐藏,并且功能没有改变,用户不需要改变他们的代码与它的交互方式
类向用户公开的功能集通常被称为公共接口。
注意
改变类的实现通常比改变类的接口更方便,因为改变接口需要你改变所有类的用户以适应新的接口。正确设计类的公共接口是创建易于使用且维护性低的类的第一步。
- 封装:这是将数据和我们可以对其执行的操作组合在一起的原则。由于数据被隐藏在类中,用户无法访问或操作它。类必须提供与它交互的功能。C++通过允许用户将用于与类交互的操作和实现这些操作所使用的数据放在同一个单元中来实现封装:类。
让我们探索 C++中类的结构及其相关信息。以下是一个类的基本结构:
class ClassName {
// class body
};
注意
在关闭花括号后忘记添加最后一个分号是很常见的。请务必确保添加它。
C++数据成员和访问说明符
在类的主体内部,我们可以定义以下类成员:
-
数据成员:这些是存在于类内部的变量,看起来像变量声明,但位于类体内部。它们也被称为字段。
-
成员函数:这些是可以访问类内部变量的函数。它们看起来像函数声明,但位于类体内部。它们也被称为方法。
如我们之前提到的,类通过拒绝用户访问信息来支持信息隐藏。程序员使用访问修饰符来指定类的哪些部分可供用户访问。
C++中有以下三种访问修饰符:
-
private
只能被类内部的函数访问,并且不允许在类外部直接访问 -
protected
只能被类内部和派生类中的函数访问。我们将在本书的最后一章中了解更多 -
public
可以在程序的任何地方访问
访问修饰符后面跟着冒号来限定类中的一个区域,并且在该区域中定义的任何成员都具有它前面的访问修饰符。以下是语法:
class ClassName {
private:
int privateDataMember;
int privateMemberFunction();
protected:
float protectedDataMember;
float protectedMemberFunction();
public:
double publicDataMember;
double publicMemberFunction();
};
注意
默认情况下,类成员具有private
访问修饰符。
在 C++中,我们也可以使用struct
关键字来定义一个类。struct
与类相同,唯一的区别是,默认情况下,访问修饰符是public
,而对于类来说,它是private
。
以下并排的代码片段是等效的:
图 3.2:类和 struct 代码片段之间的区别
是否使用struct
或class
取决于使用的约定:通常,当我们想要一个可以从代码的任何地方访问的数据成员集合时,我们使用structs
;另一方面,当我们正在模拟一个更复杂的概念时,我们使用类。
我们已经学习了如何定义一个类。现在,让我们了解如何在程序中使用它。
一个类定义了一个对象的蓝图或设计。就像蓝图一样,我们可以从同一个类创建多个对象。这些对象被称为实例。
我们可以以创建任何基本类型相同的方式创建一个实例:先定义变量的类型,然后是变量的名称。让我们看看以下示例。
class Coordinates {
public:
float latitude;
float longitude;
float distance(const Coordinates& other_coordinate);
};
以下是一个示例,展示了具有多个实例的类:
Coordinates newYorkPosition;
Coordinates tokyoPosition;
在这里,我们有Coordinates
类的两个实例,每个实例都有其latitude
和longitude
,它们可以独立改变。一旦我们有一个实例,我们就可以访问它的成员。
当我们声明一个类时,我们创建一个新的作用域,称为class
或struct
,从类外部的作用域是点(.
)运算符。
对于之前定义的变量,我们可以使用以下代码来访问它们的latitude
:
float newYorkLatitude = newYorkPosition.latitude;
如果我们想调用一个成员函数,我们可以这样调用它:
float distance = newYorkPosition.distance(tokyoPosition);
另一方面,当我们编写class
方法的主体时,我们处于类的范围内。这意味着我们可以通过直接使用它们的名称来访问类的其他成员,而无需使用点运算符。当前实例的成员将被使用。
假设distance
方法实现如下:
float Coordinates::distance(const Coordinates& other_coordinate) {
return pythagorean_distance(latitude, longitude, other_coodinate.latitude, other_coordinate.longitude);
}
当我们调用 newYorkPosition.distance(tokyoPosition);
时,distance
方法是在 newYorkPosition
实例上被调用的。这意味着 distance
方法中的 latitude
和 longitude
指的是 newYorkPosition.latitude
和 newYorkPosition.longitude
,而 other_coordinate.latitude
指的是 tokyoPosition.latitude
。
如果我们调用 tokyoPosition.distance(newYorkPosition);
,当前的实例将是 tokyoPosition
,而 latitude
和 longitude
将指向 tokyoPosition
,other_coordinate
则指向 newYorkPosition
。
静态成员
在上一节中,我们了解到一个类定义了组成对象的字段和方法。它就像一个蓝图,指定了对象的外观,但实际上并没有构建它。一个实例是由类定义的蓝图构建的对象。实例包含数据,我们可以对实例进行操作。
想象一下汽车的蓝图。它指定了汽车的引擎,以及汽车将有四个轮子。蓝图是汽车类,但我们不能启动并驾驶蓝图。按照蓝图构建的汽车是类的实例。构建的汽车有四个轮子和引擎,我们可以驾驶它。同样,类的实例包含由类指定的字段。
这意味着每个字段的值都与类的特定实例相关联,并独立于所有其他实例的字段演变。同时,这也意味着字段不能没有关联的实例存在:将没有对象能够提供存储(内存中的空间)来存储字段的值!
然而,有时我们希望所有实例共享相同的值。在这种情况下,我们可以通过创建一个 static
字段,将字段与类关联而不是与实例关联。让我们检查以下语法:
class ClassName {
static Type memberName;
};
将只有一个 memberName
字段,它被所有实例共享。像任何 C++ 变量一样,memberName
需要存储在内存中。我们不能使用实例对象的存储,因为 memberName
与任何特定实例无关。memberName
以类似 全局变量 的方式存储。
在声明 static
变量的类之外,在一个 .cpp
文件中,我们可以定义 static
变量的值。初始化值的语法如下:
Type ClassName::memberName = value;
注意
注意,我们不需要重复 static
关键字。
在 .cpp
文件中定义 static
变量的值非常重要。如果我们将其定义在 头文件 中,定义将被包含在头文件内的任何地方,这将创建多个定义,并且 链接器 将会报错。
类静态变量的生命周期持续整个程序运行期间,就像全局变量一样。
让我们看看一个类中的静态字段如何在头文件中定义,以及如何在.cpp
文件中为其赋值:
// In the .h file
class Coordinates {
// Data member
float latitude_ = 0;
// Data member
float longitude_ = 0;
public:
// Static data member declaration
static const Coordinates hearthCenter;
// Member function declaration
float distanceFrom(Coordinates other);
// Member function definition
float distanceFromCenter() {
return distanceFrom(hearthCenter);
}
};
// In the .cpp file
// Static data member definition
const Coordinates Coordinates::hearthCenter = Coordinates(0, 0);
当访问实例的成员时,我们学习了如何使用点运算符。
当访问静态成员时,我们可能没有实例来使用点运算符。C++通过在类名后使用::
运算符,给了我们访问类静态成员的能力。
注意
声明静态字段时始终使用const
。任何实例都可以访问其类的静态字段;如果它们是可变的,那么追踪哪个实例正在修改值就变得极其困难。在多线程程序中,同时从不同的线程修改静态字段是常见的,这会导致创建错误。
让我们检查以下练习,以了解静态变量是如何工作的。
练习 7:使用静态变量
让我们编写一个程序来打印和查找从 1 到 10 的数字的平方:
-
包含所需的头文件。
-
编写
squares()
函数和以下逻辑:void squares() { static int count = 1; int x = count * count; x = count * count; std::cout << count << "*" << count; std::cout << ": " << x <<std::endl; count++; }
-
现在,在
main
函数中,添加以下代码:int main() { for (int i=1; i<11; i++) squares(); return 0; }
输出如下:
1*1: 1 2*2: 4 3*3: 9 4*4: 16 5*5: 25 6*6: 36 7*7: 49 8*8: 64 9*9: 81 10*10: 100
除了静态字段之外,类还可以有静态方法。
静态方法与类相关联;它可以不通过实例来调用。由于类的字段和成员与实例相关联,而静态方法则不是,因此静态方法不能调用它们。可以使用作用域解析运算符调用静态方法:ClassName::staticMethodName();
。
注意
静态方法只能调用类内部的其它静态方法和静态字段。
成员函数
成员函数是用于操作类数据成员的函数,它们定义了类的对象属性和行为。
声明成员函数只是在一个类体内部声明一个函数的事情。让我们检查以下语法:
class Car
{
public:
void turnOn() {}
};
成员函数,就像类的数据成员一样,可以使用应用于对象的点(.
)运算符来访问:
Car car;
car.turnOn();
让我们了解如何在类作用域之外声明成员函数。
声明成员函数
成员函数,就像数据成员一样,必须在类内部声明。然而,成员函数的实现可以放在类体内部或外部。
以下是在类外部定义成员函数的定义,这是通过使用作用域解析运算符(::
)来声明所引用的函数是类的成员来完成的。在类体中,函数使用其原型进行声明:
class Car
{
public:
void turnOn();
};
void Car::turnOn() {}
使用 const 成员函数
类的成员函数可以被指定为const
,这意味着该函数限制其访问为只读。此外,当成员函数访问const
成员数据时,它必须为const
。因此,不允许const
成员函数修改对象的状态或调用这样做的方法。
要将成员函数声明为const
,我们在函数声明中使用const
关键字,位于函数名之后和函数体之前:
const std::string& getColor() const
{
// Function body
}
除了我们在上一章中学到的重载规则之外,成员函数可以在它们的const-ness
上进行重载,这意味着两个函数可以具有相同的签名,除了一个是const
而另一个不是。当对象被声明为const
时,将调用const
成员函数;否则,调用非const
函数。让我们考察以下代码:
class Car
{
std::string& getColor() {}
const std::string& getColor() const {}
};
Car car;
// Call std::string& getColor()
car.getColor();
const Car constCar;
// Call const Color& getColor() const
constCar.getColor();
注意
区分const
函数和返回const
类型的函数很重要。两者都使用了相同的const
关键字,但在函数原型中的位置不同。它们表达的概念不同,是独立的。
以下示例显示了三种const
函数的版本:
-
第一个是一个
const
成员函数 -
第二个返回一个
const
引用 -
第三个是一个返回
const
引用的const
函数:type& function() const {} const type& function() {} const type& function() const {}
this
关键字
当在class
上下文中使用this
关键字时,它代表一个指针,其值是调用成员函数的对象的地址。它可以出现在任何非静态成员函数的体内。
在以下示例中,setColorToRed()
和setColorToBlue()
执行相同的操作。两者都设置一个数据成员,但前者使用this
关键字来引用当前对象:
class Car
{
std::string color;
void setColorToRed()
{
this->color = "Red";
// explicit use of this
}
void setColorToBlue()
{
color = "Blue";
// same as this->color = "Blue";
}
};
注意
pointer->member
是访问由pointer
指向的struct
成员的一种方便方式。它等价于(*pointer).member
。
练习八:使用this
关键字创建一个问候新用户的程序
让我们编写一个程序,要求用户输入他们的名字,并用欢迎信息问候他们:
-
首先,包含所需的头文件。
-
然后,添加以下函数以打印所需输出:
class PrintName { std::string name; };
-
现在,让我们使用
this
关键字完成程序,并添加一个结束语。在之前的类中定义以下方法:public: void set_name(const std::string &name){ this->name = name; } void print_name() { std::cout << this->name << "! Welcome to the C++ community :)" << std::endl; }
-
编写如下
main
函数:int main() { PrintName object; object.set_name("Marco"); object.print_name(); }
输出如下:
Marco! Welcome to the C++ community :)
注意
函数参数与类的数据成员同名时,会遮蔽其可见性。在这种情况下,需要使用
this
关键字来消除歧义。
非成员类相关函数
定义为属于类接口的概念性函数或操作,非成员类相关函数不是类本身的一部分。让我们考察以下示例:
class Circle{
public:
int radius;
};
ostream& print(ostream& os, const Circle& circle) {
os << "Circle's radius: " << circle.radius;
return os;
}
打印函数将圆的半径写入给定的流,这通常是标准输出。
活动七:通过获取器和设置器实现信息隐藏
在这个活动中,你被要求定义一个名为Coordinates
的类,它包含两个数据成员,latitude
和longitude
,都是float
类型且不可公开访问。
与Coordinates
类相关联的操作有四个:set_latitude
、set_longitude
、get_latitude
和get_longitude
。
注意
set_latitude
和set_longitude
操作用于设置x
和y
坐标(也称为get_latitude
和get_longitude
用于检索它们(有时称为获取器)。
通过成员函数使用获取器和设置器进行封装。
要执行此活动,请按照以下步骤进行:
-
定义一个名为
Coordinates
的类,其成员在private
访问指定符下。 -
将之前指定的四个操作添加,并通过在它们的声明前加上
public
访问指定符使它们公开可访问。 -
设置器(
set_latitude
和set_longitude
)应该接受一个浮点数作为参数并返回void
,而获取器不接收任何参数并返回一个float
。 -
现在应该实现这四个方法。设置器将给定的值赋给它们应该设置的相应成员;获取器返回存储的值。
注意
这个活动的解决方案可以在第 288 页找到。
构造函数和析构函数
到目前为止,我们已经学习了如何声明数据成员,如何在带有public
指定符的函数中使用它们,以及如何访问它们。现在,让我们来探讨如何给它们设置值。
在下面的例子中,我们将声明一个名为Rectangle
的struct
,并按照以下方式为其设置值:
struct Rectangle {
int height;
int width;
};
Rectangle rectangle;
// What will the following print function print?
std::cout << "Height: " << rectangle.height << std::endl;
这行代码将打印一个随机值,因为我们从未设置int
的值。C++对基本类型的初始化规则是它们获得非指定值。
注意
在某些情况下,当变量未初始化时,它们的值被设置为0
。这可能是由于操作系统实现、标准库或编译器的某些细节导致的,C++标准不保证这一点。当程序依赖于这种行为时,程序将出现奇怪的错误,因为变量初始化为0
是不可预测的。始终显式初始化基本类型的变量。
构造函数
初始化数据成员的方式是通过使用构造函数。构造函数是一个特殊的成员函数,它具有与类相同名称且没有返回类型,当编译器创建类的新对象时,它会被自动调用。
与任何其他函数一样,构造函数可以接受参数并具有函数体。我们可以在变量的名称后添加参数列表来调用构造函数:
Rectangle rectangle(parameter1, paramter2, ..., parameterN);
当没有参数时,我们可以避免使用括号,就像我们在前面的例子中所做的那样。
Rectangle
结构体不带参数的构造函数示例如下:
struct Rectangle {
int height, width;
Rectangle() {
height = 0;
width = 0;
}
};
注意
当构造函数所做的唯一操作是初始化数据成员时,可以选择使用初始化列表,我们将在本章后面向您展示。
除了为数据成员赋值外,构造函数还可以执行代码,类似于正常函数体。这对于类不变性的概念很重要。
将类的实现隐藏在private
成员中,并且只通过public
方法与类表示的概念进行交互的关键优势是能够强制执行类不变性。
类不变性是类的属性或属性集,对于类的任何给定实例,在任何点上都应该是true
。它被称为true
。
让我们看看一个需要类不变性的类的示例。想象一下,我们想要创建一个表示日期的类。日期将包含年、月和日,所有这些都表示为整数。
将其实现为一个struct
,其中所有字段都是public
。参考以下代码:
struct Date {
int day;
int month;
int year;
};
现在,用户可以轻松地执行以下操作:
Date date;
date.month = 153;
之前的代码没有意义,因为格里高利历中只有 12 个月。
对于日期,一个类不变性是月份总是在 1 到 12 之间,日期总是在 1 到 31 之间,并且根据月份,甚至更少。
不论用户对Date
对象进行的任何更改,不变性都必须始终成立。
一个类可以隐藏日期存储为三个整数的事实,并公开与Date
对象交互的函数。函数可以期望找到的日期始终处于有效状态(不变性在函数开始时得到满足),并且它们需要确保在函数结束时将类留在有效状态(不变性在函数结束时得到满足)。
构造函数不仅初始化数据成员,还确保类遵守不变性。在构造函数执行后,不变性必须是true
。
注意
不变性的概念并不仅限于 C++语言,并且没有专门的设施来指定类的不变性。一个最佳实践是在类代码中一起记录类的预期不变性,以便与该类一起工作的开发者可以轻松地检查预期的不变性并确保他们遵守它。
在代码中使用断言也有助于识别何时不遵守不变性。这可能意味着代码中存在错误。
重载构造函数
与其他函数类似,我们可以通过接受不同的参数来重载构造函数。当对象可以通过多种方式创建时,这很有用,因为用户可以通过提供预期的参数来创建对象,并且将调用正确的构造函数。
我们在本章前面展示了Rectangle
类的默认构造函数的示例。如果我们想添加一个从正方形创建矩形的构造函数,我们可以在Rectangle
类中添加以下构造函数:
class Rectangle {
public:
Rectangle(); // as before
Rectangle (Square square);
};
第二个构造函数是一个重载构造函数,它将根据类对象的初始化方式被调用。
在以下示例中,第一行将调用带有空参数的构造函数,而第二行将调用重载构造函数:
Rectangle obj; // Calls the first constructor
Rectangle obj(square); // Calls the second overloaded constructor
注意
只有一个非默认参数的构造函数也称为转换构造函数。这种构造函数指定了一个隐式转换,即从参数的类型转换为类类型。
根据之前的定义,以下转换是可能的:
Square square;
Rectangle rectangle(square);
构造函数被初始化,并且它从类型Square
转换为Rectangle
。
同样,编译器在调用函数时也可以创建隐式转换,如下面的示例所示:
void use_rectangle(Rectangle rectangle);
int main() {
Square square;
use_rectangle(square);
}
当调用use_rectangle
时,编译器通过调用转换构造函数创建一个新的Rectangle
类型的对象,该构造函数接受一个Square
。
避免这种情况的一种方法是在构造函数定义前使用explicit
指定符:
explicit class_name(type arg) {}
让我们看看Rectangle
的另一种实现,它有一个显式构造函数:
class ExplicitRectangle {
public:
explicit ExplicitRectangle(Square square);
};
当我们尝试使用Square
调用一个接受ExplicitRectangle
的函数时,我们会得到一个错误:
void use_explicit_rectangle(ExplicitRectangle rectangle);
int main() {
Square square;
use_explicit_rectangle(square); // Error!
}
构造函数成员初始化
如我们所见,构造函数用于初始化成员。到目前为止,我们通过直接给成员赋值在函数体内初始化成员。C++提供了一种更方便的方式来初始化类的字段值:初始化列表。初始化列表允许你在执行构造函数体之前调用类的数据成员的构造函数。要编写初始化列表,在构造函数体之前插入一个冒号(:
)和一个用逗号分隔的初始化成员列表。
让我们看看以下示例:
class Rectangle {
public:
Rectangle(): width(0), height(0) { } //Empty function body, as the variables have already been initialized
private:
int width;
int height;
};
注意,在这个最后的情况下,构造函数除了初始化其成员外没有做任何事情。因此,它有一个空函数体。
现在,如果我们尝试打印Rectangle
对象的宽度和高度,我们会注意到它们被正确地初始化为0
:
Rectangle rectangle;
std::cout << "Width: " << rectangle.width << std::endl; // 0
std::cout << "Height: " << rectangle.height << std::endl; // 0
初始化列表是 C++中初始化成员变量的推荐方式,当数据成员是const
时是必要的。
当使用初始化列表时,成员构造的顺序是它们在类内部声明的顺序;而不是它们在初始化列表中出现的顺序。让我们看看以下示例:
class Example {
Example() : second(0), first(0) {}
int first;
int second;
};
当调用Example
类的默认构造函数时,first
方法将首先被初始化,然后是second
方法,即使它们在初始化列表中的顺序不同。
注意
你应该始终按照成员声明的顺序在初始化列表中编写成员;当顺序与预期不同时,编译器会通过警告来帮助你。
聚合类初始化
没有用户声明的构造函数、没有私有或保护指定符的非静态数据成员、没有基类和没有虚函数的类或结构体被认为是聚合。
注意
我们将在第六章中讨论基类和虚函数。
即使这些类没有构造函数,也可以通过使用括号括起来的逗号分隔的初始化子句列表来初始化,如下所示:
struct Rectangle {
int length;
int width;
};
Rectangle rectangle = {10, 15};
std::cout << rectangle.length << "," << rectangle.width;
// Prints: 10, 15
析构函数
当对象超出作用域时,会自动调用析构函数,并用于销毁其类的对象。
析构函数与类名相同,前面有一个波浪号(~
),并且不接收任何参数也不返回任何值(甚至不是 void)。让我们来看一个例子:
class class_name {
public:
class_name() {} // constructor
~class_name() {} // destructor
};
执行析构函数的主体并销毁在主体内部分配的任何自动对象后,类的析构函数会调用所有直接成员的析构函数。数据成员的销毁顺序与它们的构造顺序相反。
练习 9:创建一个简单的坐标程序以演示构造函数和析构函数的使用
让我们编写一个简单的程序来演示构造函数和析构函数的使用:
-
首先,包含所需的头文件。
-
现在,将以下代码添加到
Coordinates
类中:class Coordinates { public: Coordinates(){ std::cout << "Constructor called!" << std::endl; } ~Coordinates(){ std::cout << "Destructor called!" << std::endl; } };
-
在
main
函数中,添加以下代码:int main() { Coordinates c; // Constructor called! // Destructor called! }
输出如下:
Constructor called! Destructor called!
默认构造函数和析构函数
所有类都需要构造函数和析构函数。当程序员没有定义这些函数时,编译器会自动创建隐式定义的构造函数和析构函数。
注意
默认构造函数可能不会初始化数据成员。具有内置或复合类型成员的类通常应在类内部初始化这些成员或定义其默认构造函数版本。
活动 8:在 2D 地图上表示位置
Alice 正在编写一个程序来展示世界地图的 2D 版本。用户需要能够保存位置,例如他们的房子、餐厅或工作场所。为了启用此功能,Alice 需要能够表示世界中的位置。
创建一个名为Coordinates
的类,其数据成员是点的 2D 坐标。为了确保对象始终正确初始化,实现一个构造函数来初始化类的数据成员。
让我们执行以下步骤:
-
第一步是创建一个名为
Coordinates
的类,其中包含坐标作为数据成员。 -
现在,有两个浮点值
_latitude
和_longitude
,它们标识地理坐标系统上的坐标。此外,这些数据成员使用private
访问修饰符定义。 -
通过添加两个参数
latitude
和longitude
的public
构造函数来扩展类,这些参数用于初始化类的数据成员。 -
Alice 现在可以使用这个
Coordinates
类来表示地图上的 2D 位置。注意
本活动的解决方案可以在第 289 页找到。
资源获取即初始化
资源获取即初始化,或简称RAII,是一种编程习惯,用于通过将其绑定到对象的生存周期来自动管理资源的生命周期。
通过智能地使用对象的构造函数和析构函数,你可以实现 RAII。前者获取资源,而后者负责实现它。当资源无法获取时,构造函数可以抛出异常,而析构函数绝不能抛出异常。
通常,当资源的使用涉及open()
/close()
、lock()
/unlock()
、start()
/stop()
、init()
/destroy()
或类似函数调用时,通过 RAII 类的实例操作资源是一种良好的实践。
以下是一种使用 RAII 风格机制打开和关闭文件的方法。
注意
C++像许多语言一样,将输入/输出操作表示为流,其中数据可以写入或读取。
类的构造函数将文件打开到提供的流中,而析构函数将关闭它:
class file_handle {
public:
file_handle(ofstream& stream, const char* filepath) : _stream(stream) {
_stream.open(filepath);
}
~file_handle {
_stream.close();
}
private:
ofstream& _stream;
};
要打开文件,只需向file_handle
类提供文件路径。然后,在整个file_handle
对象的生命周期内,文件将不会关闭。一旦对象达到作用域的末尾,文件将被关闭:
ofstream stream;
{
file_handle myfile(stream, "Some path"); // file is opened
do_something_with_file(stream);
} // file is closed here
这代替了以下代码:
ofstream stream;
{
stream.open("Some path"); // file is opened
do_something_with_file(stream);
stream.close(); // file is closed here
}
尽管应用 RAII 惯用语提供的优势似乎只是减少代码,但真正的改进是拥有更安全的代码。程序员编写一个正确打开文件但从未关闭它或分配永远不会被销毁的内存的函数是很常见的情况。
RAII 确保这些操作不会被遗忘,因为它会自动处理它们。
活动 9:在地图上存储不同位置的多组坐标
在 2D 地图程序中,用户可以在地图上保存多个位置。我们需要能够存储多个坐标以跟踪用户保存的位置。为此,我们需要一种创建可以存储它们的数组的方法:
-
使用 RAII 编程惯用语,编写一个管理内存分配和删除值数组的类。该类有一个整数数组作为成员数据,它将用于存储值。
-
构造函数接受数组的大小作为参数。
-
构造函数还负责分配内存,该内存用于存储坐标。
-
要分配内存,请使用
allocate_memory
函数(元素数量),它返回一个指向请求大小的坐标数组的指针。要释放内存,请调用release_memory
(数组),它接受一个坐标数组并释放内存。 -
最后,定义一个析构函数,并确保在其实现中释放先前分配的数组:
注意
本活动的解决方案可以在第 290 页找到。
嵌套类声明
在类的范围内,我们可以声明的不仅仅是数据成员和成员函数;我们可以在另一个类内部声明一个类。这些类被称为嵌套类。
由于嵌套类声明发生在外部类内部,它可以访问所有声明的名称,就像它是外部类的一部分:它可以访问甚至私有声明。
另一方面,嵌套类与任何实例都不相关联,因此它只能访问静态成员。
要访问嵌套类,我们可以使用双冒号(::
),类似于访问外部类的静态成员。让我们看看以下示例:
// Declaration
class Coordinate {
...
struct CoordinateDistance {
float x = 0;
float y = 0;
static float walkingDistance(CoordinateDistance distance);
}
};
// Create an instance of the nested class CoordinateDistance
Coordinate::CoordinateDistance distance;
/* Invoke the static method walkingDistance declared inside the nested class CoordinateDistance */
Coordinate::CoordinateDistance::walkingDistance(distance);
嵌套类有两个主要用途:
-
当实现一个类时,我们需要一个对象来管理类的一些逻辑。在这种情况下,嵌套类通常是私有的,并且不会通过类的公共接口暴露出来。它主要用于简化类的实现。
-
在设计类的功能时,我们希望提供一个与原始类密切相关且提供部分该功能的类。在这种情况下,该类对类的用户是可访问的,并且通常是类交互的重要部分。
想象一个列表——对象序列。我们希望用户能够遍历列表中的项目。为此,我们需要跟踪用户已经遍历过的项目和剩余的项目。这通常是通过一个List
类来完成的。
我们将在第 5 课,标准库容器和算法中更详细地了解迭代器。
友元指定符
正如我们已经看到的,类的私有和保护成员不能从其他函数和类内部访问。一个类可以声明另一个函数或类为友元:这个函数或类将有权访问声明友元关系的类的私有和保护成员。
用户必须在类的主体内部指定friend
声明。
友元函数
友元函数是非成员函数,有权访问类的私有和保护成员。将函数声明为friend
函数的方法是在类内部添加其声明,并在其前面加上friend
关键字。让我们看看以下代码:
class class_name {
type_1 member_1;
type_2 member_2;
public:
friend void print(const class_name &obj);
};
friend void print(const class_name &obj){
std::cout << obj.member_1 << " " << member_2 << std::endl;
}
在前面的例子中,声明在类作用域之外的功能有权访问类数据成员,因为它被声明为friend
函数。
友元类
同样,像friend
函数一样,一个类也可以通过使用friend
关键字成为另一个类的友元。
将一个类声明为friend
就像声明它的所有方法为友元函数一样。
注意
友情不是相互的。如果一个类是另一个类的友元,那么相反的情况并不自动成立。
以下代码演示了友情不是相互的概念:
class A {
friend class B;
int a = 0;
};
class B {
friend class C;
int b = 0;
};
class C {
int c = 0;
void access_a(const A& object) {
object.a;
// Error! A.a is private, and C is not a friend of A.
}
};
友情不是传递的;因此,在前面的例子中,类C
不是类A
的友元,类C
的方法不能访问类A
的保护或私有成员。此外,A
不能访问B
的私有成员,因为B
是A
的友元,但友情不是相互的。
练习 10:创建一个打印用户身高的程序
让我们编写一个程序,从用户那里收集以英寸为单位的高度输入,并在执行计算后打印用户以英尺为单位的高度:
-
首先,让我们将所有必需的头文件添加到程序中。
-
现在,创建一个名为
Height
的类,包含一个public
方法,如下所示:class Height { double inches; public: Height(double value): inches(value) { } friend void print_feet(Height); };
-
如您所见,在之前的代码中,我们使用了一个名为
print_feet
的友元函数。现在,让我们声明它:void print_feet(Height h){ std::cout << "Your height in inches is: " << h.inches<< std::endl; std::cout << "Your height in feet is: " << h.inches * 0.083 << std::endl; }
-
在
main
函数中调用类,如下所示:int main(){ IHeight h(83); print_feet(h); }
输出如下:
Your height in inches is: 83 Your height in feet is: 6.889
活动 10:创建苹果实例的AppleTree
类
有时,我们可能只想在有限数量的类中创建特定类型的对象,以防止创建该类型的对象。这通常发生在类之间有严格关系时。
创建一个不提供public
构造函数的Apple
类和一个负责创建前者的AppleTree
类。
让我们执行以下步骤:
-
首先,我们需要创建一个具有
private
构造函数的类。这样,对象就不能被构造,因为构造函数不是公开可访问的:class Apple { private: Apple() {} // do nothing };
-
AppleTree
类被定义,并包含一个名为createFruit
的方法,负责创建一个Apple
并返回它:class AppleTree { public: Apple createApple(){ Apple apple; return apple; } };
-
如果我们编译此代码,我们将得到一个错误。此时,
Apple
构造函数是private
的,因此AppleTree
类无法访问它。我们需要将AppleTree
类声明为Apple
的friend
,以允许AppleTree
访问Apple
的私有方法:class Apple { friend class AppleTree; private: Apple() {} // do nothing }
-
现在可以使用以下代码构造
Apple
对象:AppleTree tree; Apple apple = tree.createFruit();
注意
本活动的解决方案可以在第 291 页找到。
复制构造函数和赋值运算符
一种特殊的构造函数类型是const
修饰的。
以下代码引用了一个具有用户定义的复制构造函数的类,它将另一个对象的数据成员复制到当前对象中:
class class_name {
public:
class_name(const class_name& other) : member(other.member){}
private:
type member;
};
当类定义没有显式声明复制构造函数且所有数据成员都具有复制构造函数时,编译器会隐式地声明一个复制构造函数。这个隐式复制构造函数会按照初始化的相同顺序复制类的成员。
让我们看看一个例子:
struct A {
A() {}
A(const A& a) {
std::cout << "Copy construct A" << std::endl;
}
};
struct B {
B() {}
B(const B& a) {
std::cout << "Copy construct B" << std::endl;
}
};
class C {
A a;
B b;
// The copy constructor is implicitly generated
};
int main() {
C first;
C second(first);
// Prints: "Copy construct A", "Copy construct B"
}
当C
被复制构造时,成员按顺序复制:首先复制a
,然后复制b
。要复制A
和B
,编译器会调用这些类中定义的复制构造函数。
注意
当复制指针时,我们不是复制指向的对象,而是简单地复制对象所在的位置的地址。
这意味着当一个类包含一个指针
作为数据成员时,隐式复制构造函数只会复制指针,而不是指向的对象,因此复制的对象和原始对象将共享指针指向的对象。这有时被称为浅拷贝。
复制赋值运算符
复制对象的另一种方式是使用复制赋值运算符,与构造运算符相反,当对象已经被初始化时才会调用它。
赋值运算符的签名和实现看起来与复制构造函数非常相似,唯一的区别是前者是 =
运算符的重载,它通常返回对 *this
的引用,尽管这不是必需的。
这里是使用复制赋值运算符的一个示例:
class class_name {
public:
class_name& operator= (const class_name & other) {
member = other.member;
}
private:
type member;
};
此外,对于复制赋值运算符,当它没有显式声明时,编译器生成一个 隐式 的。至于复制构造函数,成员的复制顺序与初始化顺序相同。
在以下示例中,当复制构造函数和复制赋值运算符被调用时,它们将输出一句话:
class class_name {
public:
class_name(const class_name& other) : member(other.member){
std::cout << "Copy constructor called!" << std::endl;
}
class_name& operator= (const class_name & other) {
member = other.member;
std::cout << "Copy assignment operator called!" << std::endl;
}
private:
type member;
};
以下代码展示了两种复制对象的方法。前者使用复制构造函数,而后者使用复制赋值运算符。当它们被调用时,两种实现将打印一句话:
class_name obj;
class_name other_obj1(obj);
\\ prints "Copy constructor called!"
class_name other_obj2 = obj;
\\ prints "Copy assignment operator called!"
移动构造函数和移动赋值运算符
与复制类似,移动也允许你将一个对象的数据成员设置为与另一个数据成员相等。唯一的区别在于内容是从一个对象转移到另一个对象,从而从源对象中移除。
移动构造函数和移动赋值操作符是接受对 class
本身 rvalue
引用参数的成员:
class_name (class_name && other);
// move-constructor
class_name& operator= (class_name && other);
// move-assignment
注意
为了清晰起见,我们可以简要描述一个 rvalue
引用(通过在函数参数类型后放置一个 &&
运算符形成)为一个没有内存地址且不会超出单个表达式的值,例如,一个临时对象。
移动构造函数和移动赋值运算符允许将 rvalue
对象拥有的资源移动到 lvalue
中,而不进行复制。
当我们将构造函数或源对象赋值给目标对象时,我们将源对象的内容转移到目标对象中,但源对象需要保持有效。为此,在实现此类方法时,将源对象的数据成员重置为有效值是基本的。这是防止析构函数多次释放类的资源(如内存)所必需的。
假设存在一个可以获取、释放、重置并检查是否已重置的 Resource
。
这里是 WrongMove
构造函数的一个示例:
class WrongMove {
public:
WrongMove() : _resource(acquire_resource()) {}
WrongMove(WrongMove&& other) {
_resource = other._resource;
// Wrong: we never reset other._resource
}
~WrongMove() {
if (not is_reset_resource(_resource)) {
release_resource(_resource);
}
}
private:
Resource _resource;
}
WrongMove
类的移动构造函数会释放资源两次:
{
WrongMove first;
// Acquires the resource
{
/* Call the move constructor: we copy the resource to second, but we are not resetting it in first */
WrongMove second(std::move(first));
}
/* Second is destroyed: second._resource is released here. Since we copied the resource, now first._resource has been released as well. */
}
// First is destroyed: the same resource is released again! Error!
相反,移动构造函数应该重置其他对象的 _resource
成员,这样析构函数就不会再次调用 release_resource
:
WrongMove(WrongMove&& other) {
_resource = other._resource;
other._resource = resetted_resource();
}
如果没有提供用户定义的构造函数、析构函数、复制构造函数或复制或移动赋值运算符,编译器可以隐式生成移动构造函数和移动赋值运算符:
struct MovableClass {
MovableClass(MovableClass&& other) {
std::cout << "Move construct" << std::endl;
}
MovableClass& operator=(MovableClass&& other) {
std::cout << "Move assign" << std::endl;
}
};
MovableClass first;
// Move construct
MovableClass second = std::move(first);
// Or: MovableClass second(std::move(first));
MovableClass third;
// Move assignment
second = std::move(third);
防止隐式构造函数和赋值运算符
如果我们的类满足所有必需条件,编译器将隐式生成复制构造函数、复制赋值、移动构造函数和移动赋值。
对于我们的类不应该被复制或移动的情况,我们可以阻止这种情况发生。
为了防止生成隐式构造函数和运算符,我们可以编写构造函数或运算符的声明,并在声明末尾添加= delete
。
让我们考察以下示例:
class Rectangle {
int length;
int width;
// Prevent generating the implicit move constructor
Rectangle(Rectangle&& other) = delete;
// Prevent generating the implicit move assignment
Rectangle& operator=(Rectangle&& other) = delete;
};
运算符重载
C++类代表用户定义类型。因此,需要能够以不同的方式操作这些类型。某些运算符函数在操作不同类型时可能具有不同的意义。运算符重载允许你定义运算符应用于类类型对象时的含义。
例如,当+
运算符应用于数值类型时,与应用于以下由坐标构成的Point
类不同。语言无法指定+
运算符对用户定义类型(如Point
)应该做什么,因为它不控制这些类型,也不知道预期的行为是什么。因此,语言不定义用户定义类型的运算符。
然而,C++允许用户指定用户定义类型(包括类)的大多数运算符的行为。
下面是一个+
运算符的例子,它被定义为Point
类:
class Point
{
Point operator+(const Point &other)
{
Point new_point;
new_point.x = x + other.x;
new_point.y = y + other.y;
return new_point;
}
private:
int x;
int y;
}
以下是可以重载和不能重载的所有运算符的列表:
- 以下是可以重载的运算符:
图 3.4:可以重载的运算符
- 以下是不能重载的运算符:
图 3.5:不能重载的运算符
需要两个操作数的运算符被称为+
、-
、*
和/
。
重载二元运算符的方法需要接受一个参数。当编译器遇到运算符的使用时,它将调用位于运算符左侧的变量的方法,而右侧的变量将作为参数传递给方法。
在前面的示例中,我们看到Point
定义了+
运算符,它接受一个参数。当在Point
上使用加法操作时,代码将如下所示:
Point first;
Point second;
Point sum = first + second;
代码示例的最后一行等价于编写以下内容:
Point sum = first.operator+(second);
编译器自动将第一个表达式重写为第二个表达式。
只需要一个操作数的运算符被称为--
、++
和!
。
重载一元运算符的方法不能接受任何参数。当编译器遇到运算符的使用时,它将调用分配给该运算符的变量的方法。
例如,假设我们有一个如下定义的对象:
class ClassOverloadingNotOperator {
public:
bool condition = false;
ClassOverloadingNotOperator& operator!() {
condition = !condition;
}
};
我们将编写以下内容:
ClassOverloadingNotOperator object;
!object;
因此,代码被重写如下:
ClassOverloadingNotOperator object;
object.operator!();
注意
运算符重载可以通过两种方式实现:要么作为成员函数,要么作为非成员函数。这两种方式最终会产生相同的效果。
活动 11:对点对象进行排序
在 2D 地图应用中,我们希望能够按照从西南到东北的顺序显示用户保存的位置:为了能够按顺序显示位置,我们需要能够按照这种顺序对表示位置的点进行排序。
记住,x
坐标代表沿着西东轴的位置,而 y
坐标代表沿着南北轴的位置。
在现实场景中,为了比较两个点,我们需要比较它们的 x
和 y
坐标。要在代码中这样做,我们需要为 Point
类重载 <
操作符。我们定义的这个新函数返回一个 bool
,根据 p_1
和 p_2
的顺序返回 true
或 false
。
如果 p_1
的 x
坐标小于 p_2
的 x
坐标,则 p_1
点在顺序上比 p_2
点更早。如果它们相等,则需要比较它们的 y
坐标。
让我们执行以下步骤:
-
我们需要为之前定义的
Point
类添加一个<
操作符的重载,该重载接受另一个类型为Point
的对象作为参数,并返回一个bool
,指示该对象是否小于提供的参数,使用之前定义的比较两个点的方法: -
在这一点上,我们能够比较两个
Point
对象: -
由于在我们的示例中,
p_1.x
被初始化为1
,而p_2.x
被初始化为2
,比较的结果将是true
,这表明p_1
在顺序上比p_2
更早。注意
本活动的解决方案可以在第 293 页找到。
介绍 Functors
operator()
函数也被称为 函数调用操作符。
定义 functor
所使用的语法如下:
class class_name {
public:
type operator()(type arg) {}
};
函数调用操作符有一个返回类型,并可以接受任何类型和数量的参数。要调用对象的函数调用操作符,我们可以写出对象的名字,然后是包含传递给操作符的参数的括号。你可以想象,提供了一个调用操作符的对象可以像使用函数一样使用。以下是一个 functor
的例子:
class_name obj;
type t;
/* obj is an instance of a class with the call operator: it can be used as if it was a function */
obj(t);
它们在可以传递一个具有 operator()
定义的对象到接受该对象的算法模板的地方特别有用。这利用了代码的可重用性和可测试性。我们将在第五章讨论 lambda 时了解更多。
以下是一个简单的 functor
示例,它在字符串末尾添加一个新行之前打印一个字符串:
class logger{
public:
void operator()(const std::string &s) {
std::cout << s << std::endl;
}
};
logger log;
log ("Hello world!");
log("Keep learning C++");
活动 12:实现 Functors
编写一个函数对象,它在构造时接受一个数字,并定义一个操作符调用,该调用接受另一个数字并返回两个数字的和。
让我们执行以下步骤以实现所需的输出:
-
定义一个名为
AddX
的类,它由一个类型为int
的private
数据成员和一个用于初始化它的构造函数组成。 -
通过调用操作符
operator()
扩展它,该操作符接受一个int
类型的参数并返回一个int
。函数体内的实现应该返回先前定义的x
值与函数参数y
的和。 -
实例化我们刚刚定义的类的对象并调用调用操作符:
class AddX { public: explicit AddX(int v) : value(v) {} int operator()(int other_value) { Indent it to the right, same as above } private: int value; }; AddX add_five(5); std::cout << add_five(4) << std::endl; // prints 9
注意
该活动的解决方案可以在第 294 页找到。
摘要
在本章中,我们看到了如何在 C++中使用类概念。我们首先阐述了使用类的优点,描述了它们如何帮助我们创建强大的抽象。
我们概述了类可以使用哪些访问修饰符来控制谁可以访问类的字段和方法。
我们继续探讨类与其实例之间的概念差异,以及这在对静态字段和静态方法实现时产生的含义。
我们看到了构造函数是如何用于初始化类及其成员的,而析构函数则是用于清理由类管理的资源。
我们随后探讨了如何结合构造函数和析构函数来实现 C++著名的 RAII(资源获取即初始化)范式。我们展示了 RAII 如何使创建处理资源并使程序更安全、更易于工作的类变得简单。
最后,我们介绍了操作符重载的概念以及如何使用它来创建与内置类型一样易于使用的类。
在下一章中,我们将重点关注模板。我们将主要探讨如何实现模板函数和类,并编写适用于多种类型的代码。
第五章:第四章
泛型编程和模板
课程目标
到本章结束时,你将能够:
-
理解模板的工作原理以及何时使用它们
-
识别和实现模板函数
-
实现模板类
-
编写适用于多种类型的代码
在本章中,你将学习如何在程序中有效地使用模板。
简介
在编程时,经常会遇到针对不同类型对象重复出现的问题,例如存储对象列表、在列表中搜索元素或找到两个元素之间的最大值。
假设在我们的程序中,我们想要能够找到两个元素之间的最大值,无论是整数还是双精度浮点数。使用我们迄今为止学到的特性,我们可以编写以下代码:
int max(int a, int b) {
if ( a > b) return a;
else return b;
}
double max(double a, double b) {
if ( a> b) return a;
else return b;
}
在前面的代码中,两个函数除了参数的 类型 和 返回类型 之外是相同的。理想情况下,我们希望只编写这类操作一次,并在整个程序中重用它们。
此外,我们的 max()
函数只能用存在重载的类型来调用:在这种情况下是 int
和 double
。如果我们希望它能够与任何数值类型一起工作,我们就需要为每种数值类型编写一个 重载:我们需要提前知道所有将要用于调用它的类型,特别是当函数是面向其他开发者使用的库的一部分时,因为我们无法知道调用函数时使用的类型。
我们可以看到,在找到最大元素时,并不需要特定于整数的要求;如果元素实现了 operator<
,则可以找到两个数中的较大者,算法不会改变。在这些情况下,C++提供了一个有效的工具——模板。
模板
模板是一种定义函数或类的方法,它们可以适用于许多不同的类型,同时只需编写一次。
它们通过具有特殊类型的参数——类型参数来实现。
在编写模板代码时,我们可以使用这个类型参数,就像它是一个真实类型一样,例如 int
或 string
。
当调用模板函数或实例化模板类时,类型参数会被替换为调用代码实际使用的真实类型。
现在让我们看看 C++代码中的一个模板示例:
template<typename T>
T max(T a, T b) {
if(a>b) {
return a;
} else {
return b;
}
}
模板总是以 template
关键字开头,后跟用 尖括号 包围的模板参数列表。
模板参数列表是由逗号分隔的参数列表。在这种情况下,我们只有一个——typename T
。
typename
关键字告诉模板我们正在编写一个使用泛型类型的模板函数,我们将将其命名为 T
。
注意
你也可以用 class
关键字代替 typename
,因为它们之间没有区别。
然后,函数的定义紧随其后。在函数定义中,当我们想要引用泛型类型时,我们可以使用名称 T
。
要调用模板,我们指定模板的名称,然后是我们要用作 类型参数 的类型的列表,用尖括号括起来:
max<int>(10, 15);
这调用了模板函数 max
,指定 int
作为类型参数。我们说我们用类型 int
实例化了模板函数 max
,然后调用了该实例。
我们并不总是需要指定模板的类型参数;编译器可以从调用代码中推断它们。稍后的部分将描述这个特性。
由于模板非常强大,C++ 标准库的大部分内容都是基于模板的,我们将在 第五章,标准库容器和算法 中看到。
现在我们将深入探讨当我们编译包含模板的代码时会发生什么。
编译模板代码
与函数和类类似,模板在使用之前需要被 声明。
当编译器首次遇到程序中的模板定义时,它会解析它并仅执行通常对其他代码所做的部分检查。
这是因为编译器在解析模板时不知道将要使用哪种类型,因为类型本身就是参数。这阻止编译器执行涉及参数类型的检查,或任何依赖于它们的检查。
因此,你只有在实例化模板时才会收到一些错误的提示。
一旦我们定义了一个模板,我们就可以在我们的代码中实例化它。
当模板被实例化时,编译器查看模板的定义,并使用它生成一个新的代码实例,其中所有对类型参数的引用都被在实例化时提供的类型所替换。
例如:当我们调用 max<int>(1,2)
时,编译器查看我们之前指定的模板定义,并生成如下代码:
int max(int a, int b) {
if(a>b) {
return a;
} else {
return b;
}
}
注意
由于编译器从模板定义生成代码,这意味着完整的定义需要对调用代码可见,而不仅仅是声明,就像函数和类的情况一样。
模板仍然可以提前声明,但编译器也必须看到定义。因此,当编写应该由多个文件访问的模板时,模板的定义和声明都必须在 头文件 中。
如果模板仅在一个文件中使用,则此限制不适用。
练习 11:查找余额最高的用户的银行账户
编写一个模板函数,该函数接受两个银行账户(相同类型)的详细信息,并返回余额最高的银行账户的余额。
对于这个练习,执行以下步骤:
-
让我们创建两个名为
EUBankAccount
和UKBankAccount
的结构体,以表示具有所需基本信息的欧盟银行账户和英国银行账户,如下面的代码所示:#include <string> struct EUBankAccount { std::string IBAN; int amount; }; struct UKBankAccount { std::string sortNumber; std::string accountNumber; int amount; };
-
模板函数将不得不比较银行账户的金额。我们想要与不同的银行账户类型一起工作,因此我们需要使用一个模板:
template<typename BankAccount> int getMaxAmount(const BankAccount& acc1, const BankAccount& acc2) { // All bank accounts have an 'amount' field, so we can access it safely if (acc1.amount > acc2.amount) { return acc1.amount; } else { return acc2.amount; } }
-
现在,在
main
函数中,调用结构体和模板函数,如下所示:int main() { EUBankAccount euAccount1{"IBAN1", 1000}; EUBankAccount euAccount2{"IBAN2", 2000}; std::cout << "The greater amount between EU accounts is " << getMaxAmount(euAccount1, euAccount2) << std::endl; UKBankAccount ukAccount1{"SORT1", "ACCOUNT_NUM1", 2500}; UKBankAccount ukAccount2{"SORT2", "ACCOUNT_NUM2", 1500}; std::cout << "The greater amount between UK accounts is " << getMaxAmount(ukAccount1, ukAccount2) << std::endl; }
输出如下:
The greater amount between EU accounts is 2000 The greater amount between UK accounts is 2500
使用模板类型参数
如我们之前所见,当模板被使用时,编译器将其用作指南来生成一个具有某些具体类型的模板实例。
这意味着我们可以将类型用作一个具体类型,包括对其应用类型修饰符。
我们之前看到,类型可以通过使用const
修饰符使其成为常量来修改,我们还可以通过使用引用修饰符来获取特定类型的对象的引用:
template<typename T>
T createFrom(const T& other) {
return T(other);
}
这里,我们可以看到一个template
函数,它从一个对象的另一个实例创建一个新的对象。
由于该函数不修改原始类型,该函数希望将其作为const
引用接受。
由于我们在模板中声明了类型T
,在函数定义中我们可以使用类型修饰符以我们认为更合适的方式接受参数。
注意我们使用了两次类型:一次带有一些修饰符,一次没有修饰符。
这在使用模板和编写函数时提供了很大的灵活性,因为我们可以自由地修改类型以适应我们的需求。
类似地,我们在哪里使用模板参数有很高的自由度。
让我们看看两个具有多个模板类型参数的模板:
template<typename A, typename B>
A transform(const B& b) {
return A(b);
}
template<typename A, typename B>
A createFrom() {
B factory;
return factory.getA();
}
我们可以看到我们可以在函数参数、返回类型或直接在函数体中实例化模板参数中使用模板参数。
此外,模板参数声明的顺序不会影响模板参数可以在哪里以及如何使用。
模板参数类型的要求
在本章开头代码片段中,我们编写了一些接受任何类型的模板。实际上,我们的代码对任何类型都不起作用;例如:max()
需要类型支持<
操作。
我们可以看到对类型有一些要求。
让我们尝试理解在 C++代码中使用模板时对类型有要求意味着什么。我们将通过使用以下模板代码来做到这一点:
template<typename Container, typename User>
void populateAccountCollection (Container& container, const User& user) {
container.push_back(user.getAccount());
}
我们可以将以下函数作为主函数并编译程序:
int main() {
// do nothing
}
当我们编译这个程序时,编译成功结束,没有任何错误。
假设我们将main
函数改为以下内容:
int main() {
std::string accounts;
int user;
populateAccountCollection(accounts, user);
}
注意
我们没有指定类型给模板。我们将在本章后面看到,当编译器可以从调用中自动推断类型时。
编译器在编译时会给我们一个错误:
error: request for member 'getAccount' in 'user', which is of non-class type 'const int'
注意当我们使用模板函数时错误是如何出现的,并且它之前没有被检测到。
错误告诉我们我们试图在一个没有此类方法的整数上调用getAccount
方法。
为什么编译器在我们编写模板时没有告诉我们这一点?
原因在于编译器不知道类型 User
将会是什么;因此,它无法判断 getAccount
方法是否存在。
当我们尝试使用模板时,我们尝试使用两种特定的类型生成代码,编译器检查了这些类型是否适合模板;它们不适合,编译器给出了错误。
我们使用的类型没有满足模板类型的要求数据。
不幸的是,在当前的 C++ 标准中,即使是最近的 C++17,也没有简单的方法来指定模板的要求——为此,我们需要良好的文档。
模板有两个类型参数,因此我们可以查看每个类型的要求数据:
-
User
对象必须有一个getAccount
方法 -
Container
对象必须有一个push_back
方法
当我们调用 getAccount()
函数时,编译器发现了第一个问题,并通知我们。
为了解决这个问题,让我们声明一个合适的类,如下所示:
struct Account {
// Some fields
};
class User {
public:
Account getAccount() const{
return Account();
}
};
现在,让我们借助以下代码调用模板:
int main() {
std::string accounts;
User user;
populateAccountCollection(accounts, user);
}
我们仍然得到一个错误:
error: no matching function for call to 'std::__cxx11::basic_string<char>::push_back(Account)'
这次,错误信息不太清晰,但编译器告诉我们没有名为 push_back
的方法可以接受 basic_string<char>
(std::string
是它的别名)中的账户。原因是 std::string
有一个名为 push_back
的方法,但它只接受字符。由于我们用 Account
调用它,所以失败了。
我们需要对模板的要求更加精确:
-
返回对象的
getAccount
方法 -
接受用户
getAccount
返回类型对象的push_back
方法注意
C++标准库中的
std::vector
类型允许存储任意类型的元素序列。push_back
是一个用于在向量末尾添加新元素的方法。我们将在 第五章,标准库容器和算法 中了解更多关于向量的内容。
现在,我们将调用代码修改为考虑所有要求:
#include <vector>
int main(){
std::vector<Account> accounts;
User user;
populateAccountCollection(accounts, user);
}
这次,代码编译正确了!
这表明编译器是如何检查大多数错误的,但只有在实例化模板时才会这样做。
这也非常重要,要清楚地记录模板的要求,以便用户不必阅读复杂的错误信息来了解哪个要求没有被遵守。
注意
为了使我们的模板易于与许多类型一起使用,我们应该尽量对类型设置最少的要求。
定义函数和类模板
在上一节中,我们看到了模板在编写抽象方面的优势。在本节中,我们将探讨如何有效地在我们的代码中使用模板来创建 模板函数 和 模板类。
函数模板
在上一节中,我们学习了如何编写函数模板。
在本节中,我们将学习 C++11 引入的两个特性,使编写模板函数更容易。这两个函数是尾随返回类型和 decltype
。
让我们从 decltype
开始。decltype
是一个关键字,它接受一个表达式并返回该表达式的类型。让我们看看以下代码:
int x;
decltype(x) y;
在之前的代码中,y
被声明为整数类型,因为我们正在使用表达式 x
的类型,它是 int
。
decltype
内部可以使用任何表达式,即使是复杂的表达式,例如:
User user;
decltype(user.getAccount()) account;
让我们看看第二个特性——尾随返回类型。
我们看到函数定义以返回类型开始,然后是函数名,然后是参数。例如:
int max(int a, int b);
从 C++11 开始,可以使用尾随返回类型:在函数签名末尾指定返回类型。声明具有尾随返回类型的函数的语法是使用关键字 auto
,然后是函数名和参数,然后是一个 箭头 和 返回类型。
以下是一个尾随返回类型的示例:
auto max(int a, int b) -> int;
当编写常规函数时,这并不有利,但当编写模板并与 decltype
结合使用时,它变得很有用。
原因是 decltype
可以访问函数参数中定义的变量,返回类型可以从中计算得出:
template<typename User>
auto getAccount(User user) -> decltype(user.getAccount());
这是一个函数模板的 前向声明
示例。
注意
当用户想要提供定义时,它需要提供相同的模板声明,然后是函数体。
没有尾随返回类型,我们必须知道 user.getAccount()
返回的类型才能将其用作 getAccount()
函数的返回类型。user.getAccount()
的返回类型可能因模板参数 User
的类型而异,这意味着 getAccount
函数的返回类型可能会根据 User
类型而变化。有了尾随返回类型,我们不需要知道 user.getAccount()
返回的类型,因为它会自动确定。更好的是,当我们在函数中使用不同类型或用户更改用于实例化模板的类型之一的 getAccount
方法的返回类型时,我们的代码会自动处理它。
最近,C++14 引入了在函数声明中简单地指定 auto
的能力,而不需要尾随返回类型:
auto max(int a, int b)
返回类型由编译器自动推导,为此,编译器需要看到函数的定义——我们不能前向声明返回 auto
的函数。
此外,auto
总是返回一个值——它永远不会返回一个引用:在使用它时要注意这一点,因为我们可能会无意中创建返回值的副本。
函数模板的一个最后但很有用的特性是,如何在不需要调用它们的情况下引用它们。
到目前为止,我们只看到了如何调用函数模板,但 C++允许我们将函数作为参数传递。例如:在排序容器时,可以提供自定义的比较函数。
我们知道模板只是一个函数的蓝图,而真正的函数只有在模板实例化时才会创建。C++允许我们在不调用它的情况下实例化模板函数。我们可以通过指定模板函数的名称,然后是模板参数,而不添加调用参数来完成此操作。
让我们理解以下示例:
template<typename T>
void sort(std::array<T, 5> array, bool (*function)(const T&, const T&));
sort
是一个接受五个元素数组和两个元素比较函数指针的函数:
template<typename T>
bool less(const T& a, const T& b) {
return a < b;
}
要使用整数less
模板的实例调用sort
,我们将编写以下代码:
int main() {
std::array<int, 5> array = {4,3,5,1,2};
sort(array, &less<int>);
}
在这里,我们取一个指向整数less
实例的指针。这在使用标准模板库时特别有用,我们将在第五章,标准库容器和算法中看到。
类模板
在上一节中,我们学习了如何编写模板函数。类模板的语法与函数的语法等效:首先,是模板声明,然后是类的声明:
template<typename T>
class MyArray {
// As usual
};
并且与函数等效,要实例化一个类模板,我们使用包含类型列表的尖括号:
MyArray<int> array;
类似于函数,当模板实例化时,会生成类模板代码,并且适用相同的限制:定义需要对编译器可用,并且在模板实例化时执行一些错误检查。
正如我们在第 3 课,类中看到的,在编写类的主体时,类的名称有时会带有特殊含义。例如,构造函数函数的名称必须与类的名称匹配。
以同样的方式,在编写类模板时,可以直接使用类的名称,它将引用正在创建的特定模板实例:
template<typename T>
class MyArray {
// There is no need to use MyArray<T> to refer to the class, MyArray automatically refers to the current template instantiation
MyArray();
// Define the constructor for the current template T
MyArray<T>();
// This is not a valid constructor.
};
这使得编写模板类与编写常规类有相似的经验,并且增加了能够使用模板参数使类与泛型类型一起工作的额外好处。
与常规类一样,模板类可以有字段和方法。字段可以依赖于模板声明的类型。让我们回顾以下代码示例:
template<typename T>
class MyArray {
T[] internal_array;
};
同样,在编写方法时,类可以使用类的类型参数:
template<typename T>
class MyArray {
void push_back(const T& element);
};
类也可以有模板方法。模板方法类似于模板函数,但它们可以访问类实例数据。
让我们回顾以下示例:
template<typename T>
class MyArray {
template<typename Comparator>
void sort (const Comparator & element);
};
sort
方法将接受任何类型,并且如果类型满足方法对类型的所有要求,它将编译。
调用方法时,语法遵循调用函数的语法:
MyArray<int> array;
MyComparator comparator;
array.sort<MyComparator>(comparator);
注意
方法模板可以是非模板类的一部分。
在这种情况下,编译器有时可以推断出参数的类型,而用户不必指定它。
如果一个方法仅在类中声明,就像我们在sort
示例中所做的那样,用户可以在以后通过指定类和方法的双重模板类型来实现它:
template<typename T> // template of the class
template<typename Comparator> // template of the method
MyArray<T>::sort(const Comparator& element) {
// implementation
}
类型名称不必匹配,但保持名称一致性是一个好习惯。
类似于方法,类也可以有模板重载运算符。方法与常规类重载运算符的编写方式相同,区别在于模板的声明必须先于重载声明的声明,就像我们在方法模板中看到的那样。
最后,需要注意的一点是静态方法和静态字段如何与类模板交互。
我们需要记住,模板是针对将生成的特定类型的代码的指南。这意味着当模板类声明一个静态成员时,该成员仅在具有相同模板参数的模板实例之间共享:
template<typename T>
class MyArray {
const Static int element_size = sizeof(T);
};
MyArray<int> int_array1;
MyArray<int> int_array2;
MyArray<std::string> string_array;
int_array1
和int_array2
将共享同一个静态变量element_size
,因为它们都是同一类型:MyArray<int>
。另一方面,string_array
有一个不同的,因为它的类类型是MyArray<std::string>
。MyArray<int>
和MyArray<std::string>
,即使是从同一个类模板生成的,也是两个不同的类,因此不共享静态字段。
依赖类型
这在代码中相当常见,尤其是在与模板交互的代码中,通常会定义一些公共别名来表示类型。
一个典型的例子是容器的value_type
type alias
,它指定了包含的类型:
template<typename T>
class MyArray {
public:
using value_type = T;
};
为什么这样做?
原因在于,如果我们接受一个泛型数组作为模板参数,我们可能想找出包含的类型。
如果我们接受一个特定的类型,这个问题就不会出现。由于我们知道向量的类型,我们可以编写以下代码:
void createOneAndAppend(std::vector<int>& container) {
int new_element{}; // We know the vector contains int
container.push_back(new_element);
}
但当我们接受任何提供push_back
方法的容器时,我们如何做到这一点?
template<typename Container>
void createOneAndAppend(Container& container) {
// what type should new_element be?
container.push_back(new_element);
}
我们可以访问容器内声明的type alias
,它指定了它包含哪种类型的值,并使用它来实例化新值:
template<typename Container>
void createOneAndAppend(Container& container) {
Container::value_type new_element;
container.push_back(new_element);
}
不幸的是,此代码无法编译。
原因在于value_type
是一个依赖类型。依赖类型是从模板参数之一派生出的类型。
当编译器编译此代码时,它会注意到我们在Container
类中访问了value_type
标识符。
这可以是静态字段或type alias
。编译器在解析模板时无法知道,因为它不知道Container
类型是什么,以及它是否有type alias
或静态变量。因此,它假设我们正在访问一个静态值。如果这是情况,我们使用的语法是不正确的,因为我们仍然在访问字段后有new_element{}
。
为了解决这个问题,我们可以告诉编译器我们正在访问类中的类型,我们通过在要访问的类型前加上 typename
关键字来实现:
template<typename Container>
void createOneAndAppend(Container& container) {
typename Container::value_type new_element{};
container.push_back(new_element);
}
活动 13:从连接中读取对象
用户正在创建一个需要通过互联网连接发送和接收其当前状态的在线游戏。应用程序有几种类型的连接(TCP、UDP、套接字),每种连接都有一个 readNext()
方法,该方法返回一个包含连接内部数据的 100 个字符的 std::array
,以及一个 writeNext()
方法,该方法接受一个 100 个字符的 std::array
,并将数据放入连接中。
让我们按照以下步骤创建我们的在线应用程序:
-
应用程序想要通过连接发送和接收的对象有一个
serialize()
静态方法,它接受对象的实例并返回一个表示该对象的 100 个字符的std::array
。class UserAccount { public: static std::array<char, 100> serialize(const UserAccount& account) { std::cout << "the user account has been serialized" << std::endl; return std::array<char, 100>(); } static UserAccount deserialize(const std::array<char, 100>& blob) { std::cout << "the user account has been deserialized" << std::endl; return UserAccount(); } }; class TcpConnection { public: std::array<char, 100> readNext() { std::cout << "the data has been read" << std::endl; return std::array<char, 100>{}; } void writeNext(const std::array<char, 100>& blob) { std::cout << "the data has been written" << std::endl; } };
-
deserialize()
静态方法接受一个表示对象的 100 个字符的std::array
,并从中创建一个对象。 -
连接对象已经提供。创建具有以下声明的头文件
connection.h
:template<typename Object, typename Connection> Object readObjectFromConnection(Connection& con) { std::array<char, 100> data = con.readNext(); return Object::deserialize(data); }
-
编写一个名为
readObjectFromConnection
的函数模板,该模板接受一个连接作为唯一参数,以及从连接中读取的对象的类型作为模板类型参数。该函数返回在反序列化连接中的数据后构造的对象实例。 -
然后,使用
TcpConnection
类的实例调用该函数,提取UserAccount
类型的对象:TcpConnection connection; UserAccount userAccount = readObjectFromConnection<UserAccount>(connection);
目标是能够将用户的账户信息发送给同一在线游戏的其他连接用户,以便他们可以看到用户信息,如他们的用户名和角色的等级。
注意
本活动的解决方案可以在第 295 页找到。
活动 14:创建支持多种货币的用户账户
编写一个支持并存储多种货币的程序。按照以下步骤:
-
我们想要创建一个
Account
类,它可以存储不同货币的账户余额。 -
Currency
是一个表示特定货币中一定价值的类。它有一个名为value
的公共字段和一个名为to()
的模板函数,该函数接受一个Currency
类型的参数,并返回一个具有适当转换当前类值的该货币类型的实例:struct Currency { static const int conversionRate = CurrencyConversion; int d_value; Currency(int value): d_value(value) {} }; template<typename OtherCurrency, typename SourceCurrency> OtherCurrency to(const SourceCurrency& source) { float baseValue = source.d_value / float(source.conversionRate); int otherCurrencyValue = int(baseValue * OtherCurrency::conversionRate); return OtherCurrency(otherCurrencyValue); } using USD = Currency<100>; using EUR = Currency<87>; using GBP = Currency<78>; template<typename Currency> class UserAccount { public: Currency balance; };
-
我们的目标是编写一个
Account
类,它可以存储由template
参数提供的任何货币的当前余额。 -
用户账户必须提供一个名为
addToBalance
的方法,该方法接受任何类型的货币,并在将其转换为账户使用的正确货币后,将值加到余额上:template<typename OtherCurrency> void addToBalance(OtherCurrency& other) { balance.value += to<Currency>(other).value; }
-
用户现在理解了如何编写类模板,如何实例化它们,以及如何调用它们的模板。
注意
本活动的解决方案可以在第 296 页找到。
非类型模板参数
我们学习了如何使用模板来提供类型作为参数,以及我们如何利用这一点来编写通用代码。
C++中的模板有一个额外的特性——非类型模板参数。
非类型模板参数是一种模板参数,它不是类型——它是一个值。
当使用std::array<int, 10>
时,我们多次使用了这种非类型模板参数。
在这里,第二个参数是一个非类型模板参数,它表示数组的大小。
非类型模板参数的声明在模板的参数列表中,但与类型参数的typename
关键字不同,它以值的类型开始,后面跟着标识符。
对于作为非类型模板参数支持的类型有严格的限制:它们必须是整型。
让我们检查以下非类型模板参数声明的示例:
template<typename T, unsigned int size>
Array {
// Implementation
};
例如:在这里,我们声明了一个类模板,它接受一个类型参数和一个非类型参数。
我们已经看到函数可以直接接受参数,类可以在构造函数中接受参数。此外,常规参数的类型并不限于必须是整型。
模板参数和非模板参数之间有什么区别?为什么我们会使用非类型模板参数而不是常规参数?
主要区别在于参数是否为程序所知。像所有模板参数一样,与非模板参数不同,值必须在编译时已知。
当我们想在需要编译时评估的表达式中使用参数时,这很有用,就像我们在声明数组大小时所做的那样。
另一个优点是编译器在编译代码时可以访问该值,因此它可以在编译期间执行一些计算,从而减少在运行时执行的指令数量,使程序更快。
此外,在编译时知道一些值可以让我们的程序执行额外的检查,这样我们就可以在编译程序时而不是在程序执行时识别问题。
活动 15:为游戏中的数学运算编写矩阵类
在游戏中,通常用一种特殊的矩阵来表示角色的方向:一个四元数。我们希望编写一个Matrix
类,它将成为我们游戏内部数学运算的基础。
我们的Matrix
类应该是一个模板,它接受一个类型、行数和列数。
我们应该将矩阵的元素存储在类内部的std::array
中。
该类应该有一个名为get()
的方法,它接受行和列作为参数,并返回该位置的元素引用。
如果行或列超出了矩阵的范围,我们应该调用std::abort()
。
让我们遵循以下步骤:
-
Matrix
类接受三个模板参数——一个类型和Matrix
类的两个维度。维度是int
类型。template<typename T, int R, int C> class Matrix { // We store row_1, row_2, ..., row_C std::array<T, R*C> data; public: Matrix() : data({}) {} };
-
现在,创建一个大小为行数乘以列数的
std::array
,以便我们有足够的空间存储矩阵的所有元素。 -
添加一个构造函数以初始化数组:
-
我们向类中添加一个
get()
方法,该方法返回对元素T
的引用。该方法需要接受我们想要访问的行和列。 -
如果索引超出矩阵的范围,我们调用
std::abort()
。在数组中,我们存储第一行的所有元素,然后是第二行的所有元素,依此类推。因此,当我们想要访问第 n 行的元素时,我们需要跳过之前行的所有元素,这些元素的数量将是每行的元素数量(即列数)乘以之前的行数:T& get(int row, int col) { if (row >= R || col >= C) { std::abort(); } return data[row*C + col]; }
输出如下:
Initial matrix: 1 2 3 4 5 6
注意
该活动的解决方案可以在第 298 页找到。
附加步骤:
在游戏中,矩阵乘以向量是一个常见的操作。
向类中添加一个方法,该方法接受一个包含矩阵相同类型元素的std::array
,并返回一个包含乘法结果的std::array
。请参阅矩阵-向量乘法的定义mathinsight.org/matrix_vector_multiplication
。
附加步骤:
我们添加了一个新方法multiply
,它以常量引用的方式接受一个类型为T
、长度为C
的std::array
,因为我们没有修改它。
函数返回一个与相同类型的数组,但长度为R
?
我们遵循矩阵-向量乘法的定义来计算结果:
std::array<T, R> multiply(const std::array<T, C>& vector){
std::array<T, R> result = {};
for(int r = 0; r < R; r++) {
for(int c = 0; c < C; c++) {
result[r] += get(r, c) * vector[c];
}
}
return result;
}
使模板更容易使用
我们总是说我们需要为模板函数或类的参数提供模板参数。现在,在本节中,我们将看到 C++提供的两个特性,使模板的使用更加容易。
这些特性是默认模板参数和模板参数推导。
默认模板参数
与函数参数一样,模板参数也可以具有默认值,包括类型和非类型模板参数。
默认模板参数的语法是在模板标识符后添加等号,后跟值:
template<typename MyType = int>
void foo();
当模板为参数提供一个默认值时,用户在实例化模板时不必指定该参数。默认参数必须在不具有默认值的参数之后。
此外,在定义后续模板参数的默认类型时,可以引用之前的模板参数。
让我们看看一些错误和有效声明的示例:
template<typename T = void, typename A>
void foo();
-
T
,具有默认类型,位于模板参数A
之前,A
没有默认参数:template<typename T = A, typename A = void> void foo();
-
T
引用模板参数A
,它位于T
之后:template<typename T, typename A = T > void foo();
-
A
有一个默认值,并且没有其他没有默认值的模板参数跟在它后面。它还引用了T
,这是在模板参数A
之前声明的。
使用默认参数的原因是为模板提供一个合理的选项,但仍然允许用户在需要时提供他们自己的类型或值。
让我们看看类型参数的一个例子:
template<typename T>
struct Less {
bool operator()(const T& a, const T& b) {
return a < b;
}
};
template<typename T, typename Comparator= Less<T>>
class SortedArray;
假设的类型SortedArray
是一个始终保持其元素排序的数组。它接受它应该持有的元素类型和一个比较器。为了方便用户使用,它默认使用less
运算符作为比较器。
以下代码展示了用户如何实现它:
SortedArray<int> sortedArray1;
SortedArrat<int, Greater<int>> sortedArray2;
我们还可以看到一个默认的非类型模板参数的例子:
template<size_t Size = 512>
struct MemoryBuffer;
假设的类型MemoryBuffer
是一个在栈上预留内存的对象;程序然后将对象分配到该内存中。默认情况下,它使用 512 字节的内存,但用户可以指定不同的大小:
MemoryBuffer<> buffer1;
MemoryBuffer<1024> buffer2;
注意buffer1
声明中的空尖括号。它们是必要的,以向编译器发出信号,表明我们正在使用模板。这个要求在 C++17 中被移除,我们可以写MemoryBuffer buffer1;
。
模板参数推导
所有模板参数都需要为实例化模板而知道,但并非所有参数都需要由调用者显式提供。
模板参数推导指的是编译器自动理解用于实例化模板的一些类型的能力,而无需用户显式地输入它们。
我们将看到对于函数,因为这是大多数 C++版本所支持的。C++17 引入了推导指南,允许编译器从构造函数中为类模板执行模板参数推导,但我们不会看到它们。
模板参数推导的详细规则非常复杂,因此我们将通过例子来了解它们,以便我们能理解它们。
通常,编译器试图找到与提供的参数最接近的类型。
我们将要分析的代码如下:
template<typename T>
void foo(T parameter);
调用代码如下:
foo(argument);
参数和参数类型
我们将看到如何根据不同的参数和参数对,推导出类型:
图 4.1:不同的参数和参数类型
错误发生是因为我们不能将临时值,如 1,绑定到一个非const
引用。
如我们所见,编译器试图推导出一个类型,当它被代入参数时,尽可能与参数匹配。
编译器并不总是能找到这样的类型;在这些情况下,它会报错,用户需要提供类型。
编译器不能推导出以下任何原因的类型:
类型在参数中未使用。例如:如果类型仅在返回类型中使用,或者仅在函数体内部使用,则编译器无法推导出该类型。
参数中的类型是一个派生类型。例如:template<typename T> void foo(T::value_type a)
。编译器无法根据用于调用函数的参数找到类型T
。
了解这些规则后,我们可以总结出编写模板时模板参数顺序的最佳实践:我们期望用户提供的类型需要放在可以推导出的类型之前。
原因是用户只能以它们声明的相同顺序提供模板参数。
让我们考虑以下模板:
template<typename A, typename B, typename C>
C foo(A, B);
当调用foo(1, 2.23)
时,编译器可以推导出A
和B
,但不能推导出C
。由于我们需要所有类型,并且用户必须按顺序提供它们,因此用户必须提供所有类型:foo<int, double, and float>(1, 2.23);
。
假设我们将无法推导出的类型放在可以推导出的类型之前,如下例所示:
template< typename C, typename A, typename B>
C foo(A, B);
我们可以用foo<float>(1, 2.23)
调用该函数。然后,我们会提供用于C
的类型,编译器会自动推导出A
和B
。
以类似的方式,我们需要对默认模板参数进行推理。
由于它们需要放在最后,我们需要确保将用户更有可能想要修改的类型放在前面,因为这将迫使用户提供所有模板参数直到该参数。
活动十六:使矩阵类更容易使用
在活动十五:为游戏中的数学运算编写矩阵类中创建的Matrix
类要求我们提供三个模板参数。
现在,在这个活动中,我们希望通过要求用户只传递两个参数来使类更容易使用:Matrix
类中的行数和列数。该类还应接受第三个参数:Matrix
类中包含的类型。如果没有提供,则默认为int
。
在上一个活动中,我们在矩阵中添加了一个multiply
操作。现在我们希望用户能够通过指定类型之间如何执行乘法来自定义函数。默认情况下,我们希望使用*
运算符。为此,<functional>
头文件中存在一个名为std::multiplies
的class
模板。它的工作方式与我们之前在本章中看到的Less
类类似:
-
我们首先导入
<functional>
,以便我们可以访问std::multiplies
。 -
然后,我们将类模板中模板参数的顺序改变,使得大小参数首先出现。我们还添加了一个新的模板参数
Multiply
,这是我们用于计算向量中元素乘法的默认类型,并将其实例存储在类中。 -
我们现在需要确保
multiply
方法使用用户提供的Multiply
类型来执行乘法。 -
为了做到这一点,我们需要确保我们调用
multiplier(operand1, operand2)
而不是operand1 * operand2
,这样我们就可以使用我们存储在类内部的实例:std::array<T, R> multiply(const std::array<T, C>& vector) { std::array<T, R> result = {}; for(int r = 0; r < R; r++) { for(int c = 0; c < C; c++) { result[r] += multiplier(get(r, c), vector[c]); } } return result; }
-
添加一个我们可以如何使用该类的示例:
// Create a matrix of int, with the 'plus' operation by default Matrix<3, 2, int, std::plus<int>> matrixAdd; matrixAdd.setRow(0, {1,2}); matrixAdd.setRow(1, {3,4}); matrixAdd.setRow(2, {5,6}); std::array<int, 2> vector = {8, 9}; // This will call std::plus when doing the multiplication std::array<int, 3> result = matrixAdd.multiply(vector);
输出如下:
Initial matrix: 1 2 3 4 5 6 Result of multiplication (with plus instead of multiply): [20, 24, 28]
注意
这个活动的解决方案可以在第 300 页找到。
在模板中实现泛型
到目前为止,我们已经学习了编译器如何通过自动推断使用的类型来使我们的模板函数更容易使用。模板代码决定是否将参数作为值或引用接受,编译器为我们找到类型。但如果我们想对参数是值还是引用保持无知,并且想无论怎样都与之工作,我们该怎么办?
一个例子是 C++17 中的std::invoke
。std::invoke
是一个函数,它接受一个函数作为第一个参数,后面跟着一系列参数,并使用这些参数调用函数。例如:
void do_action(int, float, double);
double d = 1.5;
std::invoke(do_action, 1, 1.2f, d);
如果你想在调用函数之前进行日志记录,或者你想在不同的线程中执行函数,例如std::async
所做的那样,类似的例子也会适用。
让我们通过以下代码来消除差异:
struct PrintOnCopyOrMove {
PrintOnCopyOrMove(std::string name) : _name(name) {}
PrintOnCopyOrMove(const PrintOnCopyOrMove& other) : _name(other._name) { std::cout << "Copy: " << _name << std::endl; }
PrintOnCopyOrMove(PrintOnCopyOrMove&& other) : _name(other._name) { std::cout << "Move: " << _name << std::endl; }
std::string _name;
};
void use_printoncopyormove_obj(PrintOnCopyOrMove obj) {}
注意
use_printoncopyormove_obj
总是通过值接受参数。
假设我们执行以下代码:
PrintOnCopyOrMove local{"l-value"};
std::invoke(use_printoncopyormove_obj, local);
std::invoke(use_printoncopyormove_obj, PrintOnCopyOrMove("r-value"));
代码将打印以下内容:
Copy: l-value
Move: r-value
我们如何编写一个像std::invoke
这样的函数,它无论参数的引用类型(口语中称为“ref-ness”,类似于如何使用“const-ness”来讨论类型是否具有 const 资格)如何都能正常工作?
答案是转发引用。
转发引用看起来像右值引用,但它们只适用于编译器推断类型的场景:
void do_action(PrintOnCopyOrMove&&)
// not deduced: r-value reference
template<typename T>
void do_action(T&&) // deduced by the compiler: forwarding reference
注意
如果你在一个模板中看到一个类型标识符被声明,那么类型是被推断出来的,并且类型有&&,那么它是一个转发引用。
让我们看看转发引用的推断是如何工作的:
图 4.2:转发引用函数。
注意
假设类型没有被推断出来,而是被明确提供,例如:
int x = 0;
do_action<int>(x);
这里,T
将是int
,因为它被明确地说明了。
优势,正如我们之前看到的,是我们可以与任何类型的引用一起工作,当调用代码知道它可以移动对象时,我们可以利用移动构造函数提供的额外性能,但当引用更受欢迎时,代码也可以使用它。
此外,一些类型不支持复制,我们可以使我们的模板也能与这些类型一起工作。
当我们编写模板函数的主体时,参数被用作l-value
引用,我们可以编写忽略T
是l-value
引用还是r-value
引用的代码:
template<typename T>
void do_action(T&& obj) { /* forwarding reference, but we can access obj as if it was a normal l-value reference */
obj.some_method();
some_function(obj);
}
在 第三章,类 中,我们学习了当需要使用在调用后不再访问的对象时,std::move
可以使我们的代码更高效。
但我们看到了,我们永远不应该移动作为左值引用参数接收的对象,因为调用我们的代码可能在我们返回后仍然使用该对象。
当我们使用前向引用编写模板时,我们面临一个困境:我们的类型可能是一个值或一个引用,那么我们如何决定是否可以使用 std::move
?
这是否意味着我们不能利用 std::move
带来的好处?
当然,答案是 不:
template<typename T>
void do_action(T&& obj) {
do_something_with_obj(???);
// We are not using obj after this call.
}
在这种情况下,我们应该使用移动还是不使用移动?
答案是 是:如果 T
是一个值,我们应该移动;如果不是,则不应该移动。
C++ 为我们提供了一个工具来做这件事:std::forward
。
std::forward
是一个函数模板,它始终接受一个显式模板参数和一个函数参数:std::forward<T>(obj)
。
Forward
会查看 T
的类型,如果是左值引用,则简单地返回 obj
的引用,如果不是,则相当于在对象上调用 std::move
。
让我们看看它是如何工作的:
template<typename T>
void do_action(T&& obj) {
use_printoncopyormove_obj(std::forward<T>(obj));
}
现在,我们使用以下代码来调用它:
PrintOnCopyOrMove local{"l-value"};
do_action(local);
do_action(PrintOnCopyOrMove("r-value"));
do_action(std::move(local));
// We can move because we do not use local anymore
当执行时,代码将打印以下输出:
Copy: l-val
Move: r-val
Move: l-val
我们成功地编写了不依赖于类型是作为引用还是值传递的代码,消除了对模板类型参数的可能要求。
注意
模板可以有许多类型参数。前向引用可以独立应用于任何类型参数。
这很重要,因为模板代码的调用者可能知道传递值或传递引用更好,而我们的代码应该能够在需要请求特定引用类型的情况下正常工作。
我们还看到了如何仍然保持移动的优势,这对于不支持复制的某些类型是必需的。这可以使我们的代码运行得更快,即使对于支持复制的类型也是如此,而不会使我们的代码复杂化:当我们有前向引用时,我们在本应使用 std::move
的地方使用 std::forward
。
活动 17:确保在执行账户操作时用户已登录
我们希望允许我们的电子商务网站的用户执行任意操作(在这个活动的范围内,他们将添加和删除项目)在他们的购物车中。
在执行任何操作之前,我们想确保用户已登录。现在,让我们遵循以下指示:
-
确保存在一个用于识别用户的
UserIdentifier
类型,一个表示用户购物车的Cart
类型,以及一个表示购物车中任何项目的CartItem
类型:struct UserIdentifier { int userId = 0; }; struct Cart { std::vector<Item> items; };
-
确保存在一个具有签名
bool isLoggedIn(const UserIdentifier& user)
的函数,以及一个用于获取用户购物车的函数Cart getUserCart(const UserIdentifier& user)
:bool isLoggedIn(const UserIdentifier& user) { return user.userId % 2 == 0; } Cart getUserCart(const UserIdentifier& user) { return Cart(); }
-
在我们的大部分代码中,我们只能访问用户的
UserIdentifier
,我们想确保在进行任何购物车操作之前,我们总是检查用户是否已登录。 -
为了解决这个问题,我们决定编写一个名为
execute_on_user_cart
的函数模板,它接受用户标识符、一个操作和一个单一参数。该函数将检查用户是否已登录,如果是,则检索其购物车,然后执行传递购物车和单一参数的操作:template<typename Action, typename Parameter> void execute_on_user_cart(UserIdentifier user, Action action, Parameter&& parameter) { if(isLoggedIn(user)) { Cart cart = getUserCart(user); action(cart, std::forward<Parameter>(parameter)); } else { std::cout << "The user is not logged in" << std::endl; } }
-
我们想要执行的一项操作是
void remove_item(Cart, CartItem)
。我们想要执行的另一项操作是void add_items(Cart, std::vector<CartItem>)
:void removeItem(Cart& cart, Item cartItem) { auto location = std::find(cart.items.begin(), cart.items.end(), cartItem); if (location != cart.items.end()) { cart.items.erase(location); } std::cout << "Item removed" << std::endl; } void addItems(Cart& cart, std::vector<Item> items) { cart.items.insert(cart.items.end(), items.begin(), items.end()); std::cout << "Items added" << std::endl; }
注意
函数模板的参数可以用来接受函数作为参数。
目标是创建一个函数,它会在用户登录的情况下执行必要的检查,以便在整个程序中我们可以使用它来安全地执行对用户购物车所需的所有业务操作,而不会忘记检查用户的登录状态。
-
我们还可以移动不是前向引用的类型:
template<typename Action, typename Parameter> void execute_on_user_cart(UserIdentifier user, Action action, Parameter&& parameter) { if(isLoggedIn(user)) { Cart cart = getUserCart(user); action(std::move(cart), std::forward<Parameter>(parameter)); } }
-
如何使用我们在活动早期描述的操作来使用
execute_on_user_cart
函数的示例如下:UserIdentifier user{/* initialize */}; execute_on_user_cart(user, remove_item, CartItem{}); std::vector<CartItem> items = {{"Item1"}, {"Item2"}, {"Item3"}}; // might be very long execute_on_user_cart(user, add_items, std::move(items));
-
我们软件中的开发者可以编写他们需要在购物车中执行的功能,并调用
execute_on_user_cart
来安全地执行它们。注意
解决这个活动的解决方案可以在第 302 页找到。
可变模板
我们刚刚看到了如何编写一个可以独立于它们的引用性接受参数的模板。
但是,我们之前提到的标准库中的两个函数std::invoke
和std::async
有一个额外的属性:它们可以接受任意数量的参数。
以类似的方式,std::tuple
,一种类似于std::array
的类型,但可以包含不同类型的值,可以包含任意数量的类型。
模板如何接受任意数量的不同类型的参数?
在过去,解决这个问题的方法是为同一个函数提供大量的重载,或者为类或结构体提供多个实现,每个参数数量一个。
这显然是难以维护的代码,因为它迫使我们多次编写相同的代码。另一个缺点是模板参数的数量有限,所以如果你的代码需要比提供的更多的参数,你将没有方法来使用该函数。
C++11 为这个问题提供了一个很好的解决方案:参数包。
参数包是一个可以接受零个或多个模板参数的模板参数。
通过在模板参数的类型后附加…
来声明参数包。
参数包是与任何模板一起工作的功能:函数和类:
template<typename… Types>
void do_action();
template<typename… Types>
struct MyStruct;
具有参数包的模板称为可变模板,因为它是一个接受可变数量参数的模板。
实例化变长模板时,可以通过用逗号分隔来向参数包提供任意数量的参数:
do_action<int, std:string, float>();
do_action<>();
MyStruct<> myStruct0;
MyStruct<float, int> myStruct2;
Types
将包含在实例化模板时提供的参数列表。
参数包本身是一系列类型,代码不能直接与之交互。
变长模板可以通过在模式后附加 …
来展开参数包,从而使用参数包。
当一个模式展开时,它会根据其参数包中的类型数量重复,用逗号分隔。当然,为了展开,一个模式必须至少包含一个参数包。如果模式中存在多个参数,或者相同的参数出现多次,它们将同时展开。
最简单的模式是参数包的名称:Types…
。
例如:为了让函数接受多个参数,它会在函数参数中展开参数包:
template<typename… MyTypes>
void do_action(MyTypes… my_types);
do_action();
do_action(1, 2, 4.5, 3.5f);
当我们调用函数时,编译器会自动推导参数包的类型。在最后一次调用中,MyTypes
将包含 int
、double
和 float
,生成的函数签名将是 void do_action(int __p0, int __p1, double __p2, float __p3)
。
注意
模板参数列表中的参数包只能后面跟着具有默认值的模板参数,或者由编译器推导的模板参数。
最常见的是,参数包是模板参数列表中的最后一个。
函数参数 my_types
被称为函数参数包,也需要展开以便能够访问单个参数。
例如:让我们写一个变长结构体:
template<typename… Ts>
struct Variadic {
Variadic(Ts… arguments);
};
让我们写一个创建结构体的函数:
template<typename… Ts>
Variadic<Ts…> make_variadic(Ts… args) {
return Variadic<Ts…>(args…);
}
这里,我们有一个接受参数包并在其调用另一个变长结构体的构造函数时展开的变长函数。
parameter packs
函数,这是一个变长参数函数,只能在某些位置展开——最常见的是在调用函数时作为参数。
模板 parameter packs
,这是一个类型变长参数,可以在模板参数列表中展开:实例化模板时 <>
之间的参数列表。
如我们之前提到的,展开的模式可能比仅仅参数名称更复杂。
例如:我们可以访问在类型中声明的类型别名,或者我们可以对参数调用一个函数:
template<typename… Containers>
std::tuple<typename Containers::value_type…> get_front(Containers… containers) {
return std::tuple<typename Containers::value_type…>(containers.front()…);
}
我们可以这样调用:
std::vector<int> int_vector = {1};
std::vector<double> double_vector = {2.0};
std::vector<float> float_vector = {3.0f};
get_front(int_vector, double_vector, float_vector) // Returns a tuple<int, double, float> containing {1, 2.0, 3.0}
或者,我们可以将参数作为函数的参数传递:
template<typename… Ts>
void modify_and_call (Ts… args) {
do_things(modify (args)…));
}
这将调用每个参数的 modify
函数,并将结果传递给 do_things
。
在本节中,我们看到了 C++ 的变长参数功能如何让我们编写可以与任何数量和类型的参数一起工作的函数和类。
虽然编写变长模板不是日常任务,但几乎每个程序员在日常编码中都使用变长模板,因为它使得编写强大的抽象变得容易得多,标准库也广泛使用了它。
此外,在适当的情况下,变长模板可以让我们编写出在多种我们需要的情况下都能工作的表达性代码。
活动十八:使用任意数量的参数安全地在用户购物车上执行操作
在前面的活动中,我们看到了一个函数,execute_on_user_cart
,它允许我们执行接受类型为Cart
的对象和单个参数的任意函数。
在这个活动中,我们希望通过允许任何接受类型为Cart
的对象和任意数量参数的函数来扩展我们对用户购物车可以执行的操作类型:
-
将前面的活动扩展到接受任何类型的参数数量,并传递给提供的操作。
-
编写变长模板并学习如何扩展它们:
template<typename Action, typename... Parameters> void execute_on_user_cart(UserIdentifier user, Action action, Parameters&&... parameters) { if(isLoggedIn(user)) { Cart cart = getUserCart(user); action(std::move(cart), std::forward<Parameters>(parameters)...); } }
注意
这个活动的解决方案可以在第 303 页找到。
编写易于阅读的模板
到目前为止,我们已经看到了许多我们可以用来编写强大模板的功能,这些模板允许我们在面对特定问题时创建高级抽象。
但,就像往常一样,代码更多地是被阅读而不是被编写,我们应该优化可读性:代码应该表达代码的意图,而不仅仅是实现的操作。
模板代码有时会使得做到这一点变得困难,但有一些模式可以帮助。
类型别名
类型 name = type
.
在声明之后,任何使用Name的地方都将等同于使用Type。
这非常强大,原因有三:
-
它可以为复杂类型提供一个更短且更有意义的名称
-
它可以声明一个嵌套类型以简化对其的访问
-
它允许你避免在依赖类型前指定
typename
关键字
让我们看看这两个点的示例。
假设我们有一个类型,UserAccount
,它包含用户的一些字段,例如用户 ID、用户余额、用户电子邮件等。
我们希望根据账户余额将用户账户组织到一个高分数榜中,以可视化哪些用户最积极地使用我们的服务。
要做到这一点,我们可以使用需要一些参数的数据结构:要存储的类型、排序类型的方式、比较类型的方式,以及可能的其他方式。
类型可能如下所示:
template<typename T, typename Comparison = Less<T>, typename Equality = Equal<T>>
class SortedContainer;
为了便于使用,模板正确地提供了一些默认值给Comparison
和Equality
,它们使用<
和==
运算符,但我们的UserAccount
类型没有实现<
运算符,因为没有明确的排序,而==
运算符也没有达到我们的预期,因为我们只对比较余额感兴趣。为了解决这个问题,我们实现了两个结构来提供我们需要的功能:
SortedContainer<UserAccount, UserAccountBalanceCompare, UserAccountBalanceEqual> highScoreBoard;
创建一个高分数榜既冗长。
使用类型别名,我们可以编写以下内容:
using HighScoreBoard = SortedContainer<UserAccount, UserAccountBalanceCompare, UserAccountBalanceEqual>;
在此之后,我们可以直接创建HighScoreBoard
的实例,输入很少,并且清楚地指定意图:
HighScoreBoard highScoreBoard;
现在,如果我们想更改排序账户的方式,我们也有一个单独的地方可以更新。例如:如果我们还想考虑用户在服务中注册的时间长短,我们可以更改比较器。每个类型别名的用户都会更新,而不用担心忘记更新某个位置。
此外,我们清楚地有一个位置可以放置关于使用所选类型的决策的文档。
注意
当使用类型别名时,给出一个代表类型用途的名称,而不是它的工作方式。UserAccountSortedContainerByBalance
不是一个好的名称,因为它告诉我们类型是如何工作的,而不是它的意图。
第二种情况对于允许代码进行自我检查(即查看类的某些细节)非常有用:
template<typename T>
class SortedContainer {
public:
T& front() const;
};
template<typename T>
class ReversedContainer {
public:
T& front() const;
}
我们有几个容器,它们大多支持相同的操作。我们希望编写一个模板函数,它接受任何容器并返回第一个元素,即front
:
template<typename Container>
??? get_front(const Container& container);
我们如何找出返回的类型是什么?
一种常见的模式是在类内部添加类型别名,如下所示:
template<typename T>
class SortedContainer {
using value_type = T; // type alias
T& front() const;
};
现在,函数可以访问包含元素的类型:
template<typename Container>
typename Container::value_type& get_front(const Container& container);
注意
记住,value_type
依赖于Container
类型,因此它是一个依赖类型。当我们使用依赖类型时,必须在front
中使用typename
关键字。
这样,我们的代码就可以与声明了嵌套类型value_type
的任何类型一起工作。
第三个用例,即避免重复输入typename
关键字,在与遵循先前模式的代码交互时很常见。
例如:我们可以有一个接受类型的类:
template<typename Container>
class ContainerWrapper {
using value_type = typename Container::value_type;
}
在类的其余部分,我们可以直接使用value_type
,而无需再输入typename
。这使我们能够避免大量的重复。
这三种技术也可以组合使用。例如:你可以有如下所示的内容:
template<typename T>
class MyObjectWrapper {
using special_type = MyObject<typename T::value_type>;
};
模板类型别名
如本章前一部分所述,创建类型别名的功能已经非常有助于提高我们代码的可读性。
C++赋予我们定义泛型类型别名的功能,以便它们可以被我们的代码的用户简单地重用。
模板别名是一个生成别名的模板。
如本章中我们看到的所有模板一样,它们以模板声明开始,然后是别名声明,该声明可以依赖于模板中声明的类型:
template<typename Container>
using ValueType = typename Container::value_type;
ValueType
是一个模板别名,可以使用常规模板语法实例化:ValueType<SortedContainer> myValue;
。
这允许代码在需要访问任何容器内的value_type
类型时,只需使用别名ValueType
。
模板别名可以结合模板的所有特性:它们可以接受多个参数,接受非类型参数,甚至使用参数包。
摘要
在本章中,学生们被引入了 C++中的模板。我们了解到模板的存在是为了创建在运行时零开销且独立于对象类型的高层抽象。我们解释了类型要求的概念:类型必须满足的要求才能与模板正确工作。然后我们向学生们展示了如何编写函数模板和类模板,并提到了依赖类型,以给学生提供理解在编写模板代码时可能发生的错误类别的工具。
我们展示了模板如何与非类型参数一起工作,以及如何通过提供默认模板参数来简化模板的使用,这得益于模板参数推导。
我们向学生们展示了如何利用前向引用、std::forward
和模板参数包来编写更通用的模板。
最后,我们总结了使模板更容易阅读和维护的一些工具。
在下一章中,我们将介绍标准库容器和算法。
第六章:第五章
标准库容器和算法
本章目标
到本章结束时,你将能够:
-
解释迭代器是什么
-
展示使用顺序容器、容器适配器和关联容器
-
理解和使用非传统容器
-
解释迭代器失效的情况
-
发现标准库中实现的自定义算法
-
使用 lambda 表达式在算法中执行用户定义的操作
简介
C++ 的核心是其 标准模板库(STL),它代表了一组重要的数据结构和算法,有助于简化程序员的任务并提高代码效率。
STL 的组件是参数化的,因此它们可以被以不同的方式重用和组合。STL 主要由容器类、迭代器和算法组成。
容器用于存储特定类型的元素集合。通常,容器的类型是一个模板参数,它允许相同的容器类支持任意元素。有几个容器类,每个类都有不同的特性和功能。
迭代器用于遍历容器中的元素。迭代器为程序员提供了一个简单且通用的接口来访问不同类型的容器。
迭代器类似于原始指针,它们可以使用增量或减量运算符遍历元素,或者使用解引用(*
)运算符访问特定元素。
算法用于在容器中执行标准操作。它们使用迭代器遍历集合,因为它们的接口对所有容器都是通用的,这样算法就可以对它操作的容器一无所知。
算法将函数作为参数处理,这些参数由程序员提供,以便在执行操作时更加灵活。通常,算法应用于用户定义类型的对象容器。为了正确执行,该算法需要知道如何详细处理对象。因此,程序员向算法提供一个函数,以指定对对象要执行的操作。
顺序容器
顺序容器,有时也称为 顺序容器,是一类特定的容器,其中元素的存储顺序由 程序员 决定,而不是由元素值决定。每个元素都有一个特定的位置,与其值无关。
STL 包含五个序列容器类:
图 5.1:展示序列容器类及其描述的表格
数组
数组容器是一个固定大小的连续元素数据结构。它使我们想起了在 第一章,入门 中看到的静态数组:
图 5.2:数组元素存储在连续内存中
数组的大小需要在编译时指定。一旦定义,数组的大小不能更改。
当数组被创建时,它包含的size
个元素在内存中相邻初始化。虽然不能添加或删除元素,但可以修改它们的值。
可以使用访问运算符和相应元素的索引随机访问数组。要访问给定位置的元素,我们可以使用运算符[]
或at()
成员函数。前者不执行任何范围检查,而后者如果索引超出范围,则抛出异常。此外,可以使用front()
和back()
成员函数访问第一个和最后一个元素。
这些操作很快:由于元素是连续的,我们可以根据数组中的位置计算出元素的内存位置,并直接访问它。
可以使用size()
成员函数获取数组的大小。是否为空容器可以使用empty()
函数检查,如果size()
为零,则返回true。
数组类在<array>
头文件中定义,在使用之前必须包含。
向量
向量容器是一个可以动态修改大小的连续元素数据结构:在创建时不需要指定其大小:
图 5.3:向量元素是连续的,并且其大小可以动态增长
vector
类在<vector>
头文件中定义。
向量将包含的元素存储在内存的单个部分中。通常,这个内存部分有足够的空间来存储比向量中存储的元素数量更多的元素。当向向量中添加新元素时,如果内存部分中有足够的空间,则该元素将被添加到向量的最后一个元素之后。如果没有足够的空间,向量将获得一个新的、更大的内存部分,并将所有现有元素复制到新的内存部分中,然后删除旧的内存部分。对我们来说,这会给人一种内存部分大小增加的印象:
图 5.4:向量的内存分配
当向量被创建时,它是空的。
大多数接口与数组类似,但有一些差异。
可以使用push_back()
函数追加元素,或使用insert()
函数在通用位置插入元素。可以使用pop_back()
移除最后一个元素,或使用erase()
函数在通用位置移除元素。
添加或删除最后一个元素是快速的,而插入或删除 vector 的其他元素被认为是慢的,因为这需要移动所有元素来为新元素腾出空间或保持所有元素连续:
图 5.5:在 vector 中插入或删除元素时正在移动的元素
向量,就像数组一样,允许高效地访问随机位置的元素。向量的大小也可以通过 size()
成员函数检索,但不应与 capacity()
混淆。前者是向量中实际元素的数量,后者返回当前内存区域可以插入的最大元素数量。
例如,在先前的图中,最初数组的大小为 4,容量为 8。因此,即使需要将元素移动到右侧,vector 的容量也不会改变,因为我们从未需要获取一个更大的内存区域来存储元素。
获取新内存区域的操作称为重新分配。由于重新分配被认为是一项昂贵的操作,因此可以通过使用 reserve()
成员函数扩大 vector 的容量来为给定数量的元素预留足够的内存。还可以使用 shrink_to_fit()
函数来减少 vector 的容量以适应元素数量,从而释放不再需要的内存。
注意
向量是最常用的元素序列容器,并且在性能方面通常是最佳选择。
让我们通过以下示例来了解 C++ 中的 vector::front()
和 vector::back()
是如何工作的:
#include <iostream>
#include <vector>
// Import the vector library
int main()
{
std::vector<int> myvector;
myvector.push_back(100);
// Both front and back of vector contains a value 100
myvector.push_back(10);
// Now, the back of the vector holds 10 as a value, the front holds 100
myvector.front() -= myvector.back();
// We subtracted front value with back
std::cout << "Front of the vector: " << myvector.front() << std::endl;
std::cout << "Back of the vector: " << myvector.back() << std::endl;
}
Output:
Front of the vector: 90
Back of the vector: 10
双端队列
deque 容器(发音为 deck)代表“双端队列”。与 vector 类似,它允许快速直接访问 deque 元素,并在两端快速插入和删除。与 vector 不同,它还允许在 deque 的前端快速插入和删除:
图 5.6:双端队列元素可以在开始和结束处添加和删除
deque
类定义在 <deque>
头文件中。
deque 通常比 vector 需要更多的内存,而 vector 在访问元素和 push_back
操作方面性能更优,因此除非需要在前端插入,否则通常更倾向于使用 vector。
列表
列表容器是一种非相邻元素的数据结构,可以动态增长:
图 5.7:列表元素存储在内存的不同部分,并且有连接的链接
list
类定义在 <list>
头文件中。
列表中的每个元素都有其内存段以及指向其前驱和后继的链接。包含元素的结构,即指向其前驱和后继的链接,称为 节点。
当在列表中插入元素时,需要更新前驱节点,以便其后继链接指向新元素。同样,后继节点也需要更新,以便其前驱链接指向新元素:
图 5.8:C 应插入到 A 和 B 之间。A 的后继节点和 B 的前驱链接必须更新以指向 C(橙色)。C 的前驱和后继链接更新为指向 A 和 B(绿色)
当从列表中删除元素时,需要更新前驱节点的前驱链接,使其指向被删除节点的后继。同样,后继节点的前驱链接需要更新,使其指向被删除节点的前驱。
在前面的图中,如果我们删除 C,我们必须更新 A 的后继以指向 C 的后继(B),以及 B 的前驱以指向 C 的前驱(A)。
与向量不同,列表不提供随机访问。元素通过线性遍历元素链来访问:从第一个开始,我们可以跟随后继链接找到下一个节点,或者从最后一个节点开始,我们可以跟随前驱链接找到前一个节点,直到我们达到感兴趣的元素。
list
的优点是,如果我们已经知道想要插入或删除的节点,则插入和删除操作在任何位置都很快。缺点是到达特定节点较慢。
接口类似于向量,除了列表不提供 operator[]
。
前向链表
forward_list
容器类似于列表容器,不同之处在于其节点只有指向后继的链接。因此,无法以反向顺序遍历 forward_list
:
图 5.9:前向链表元素类似于链表元素,但只有单向连接链接
如常,forward_list
类在 <forward_list>
头文件中定义。
forward_list
类甚至不提供 push_back()
或 size()
。插入元素是通过 insert_after()
完成的,它是 insert()
函数的一种变体,其中新元素被插入到提供的位置之后。同样的原理也适用于元素删除,通过 erase_after()
完成删除,它删除提供的位置之后的元素。
向序列容器提供初始值
我们查看的所有序列容器在首次创建时都是空的。
当我们想要创建包含一些元素的容器时,对于每个元素重复调用push_back()
或insert()
函数可能会很繁琐。
幸运的是,所有容器在创建时都可以使用一系列元素进行初始化。
必须在花括号内提供序列,并且元素需要用逗号分隔。这被称为初始化列表:
#include <vector>
int main()
{
// initialize the vector with 3 numbers
std::vector<int> numbers = {1, 2, 3};
}
这适用于本章中我们看到的任何容器。
活动第 19 项:存储用户账户
我们想要存储 10 个用户的账户余额,存储为int
实例,初始值为 0。然后我们想要将第一个和最后一个用户的余额增加 100。
这些步骤将帮助您完成活动:
-
包含
array
类的头文件。 -
声明一个包含十个元素的整数数组。
-
使用
for
循环初始化数组。使用size()
运算符来评估数组的大小,以及使用operator[]
来访问数组的每个位置。 -
更新第一个和最后一个用户的值。
注意
本活动的解决方案可以在第 304 页找到。
现在让我们使用 vector 来做同样的事情:
-
包含vector头文件。
-
声明一个整数类型的
vector
并预留内存以存储 100 个用户,并能够调整大小以容纳 10 个用户。 -
使用 for 循环初始化 vector。
通过这个活动,我们学习了如何存储任意数量的账户。
关联容器
operator<
,尽管用户可以提供一个Functor
(函数对象)作为参数来指定元素应该如何比较。《头文件包含许多这样的对象,可以用于排序关联容器,如
std::less或
std::less`。
图 5.10:展示关联容器及其描述的表格
通常,关联容器被实现为二叉树的变体,通过利用底层结构的对数复杂度提供快速的元素查找。
Set 和 Multiset
Set是一个包含一组排序元素的容器。Multiset与Set类似,但它允许重复元素:
图 5.11:Set 和 Multiset 存储一组排序后的元素
set
和multiset
有size()
和empty()
成员函数来检查包含了多少个元素以及是否包含任何元素。
插入和删除操作通过insert()
和erase()
函数完成。由于元素的顺序由比较器决定,它们不需要像顺序容器那样提供一个位置参数。插入和删除操作都很快速。
由于集合优化了元素查找,它们提供了特殊的搜索函数。find()
函数返回与提供的值相等的第一个元素的位置,如果找不到元素,则返回集合末尾之后的位置。当我们使用 find
查找元素时,我们应该始终将其与容器上调用 end()
的结果进行比较,以检查元素是否被找到。
让我们检查以下代码:
#include <iostream>
#include <set>
int main() {
std::set<int> numbers;
numbers.insert(10);
if (numbers.find(10) != numbers.end()) {
std::cout << "10 is in numbers" << std::endl;
}
}
最后,count()
返回与提供的值相等的元素数量。
set
和 multiset
类定义在 <set>
头文件中。
自定义比较器的集合示例:
#include <iostream>
#include <set>
#include <functional>
int main() {
std::set<int> ascending = {5,3,4,2,1};
std::cout << "Ascending numbers:";
for(int number : ascending) {
std::cout << " " << number;
}
std::cout << std::endl;
std::set<int, std::greater<int>> descending = {5,3,4,2,1};
std::cout << "Descending numbers:";
for(int number : descending) {
std::cout << " " << number;
}
std::cout << std::endl;
}
输出:
递增数字:1 2 3 4 5
递减数字:5 4 3 2 1
Map 和 Multimap
Map 和 multimap 是管理 键/值 对作为元素的容器。元素根据提供的比较器自动排序并应用于 键:值 不影响元素的顺序:
图 5.12:Map 和 multimap 存储一组排序后的键,这些键与值相关联
Map 允许您将单个值与键关联,而 multimap 允许您将多个值与相同的键关联。
map
和 multimap
类定义在 <map>
头文件中。
要将值插入到映射中,我们可以调用 insert()
,如果元素被插入,则提供 true
,如果已存在具有相同键的元素,则提供 false
。
一旦将值插入到映射中,就有几种方法可以在映射中查找键/值对。
与集合类似,map 提供了一个 find()
函数,该函数在映射中查找一个键,如果存在,则返回键/值对的位置,如果找不到元素,则返回调用 end()
的相同结果。
从位置,我们可以使用 position->first
访问键,使用 position->second
访问值:
#include <iostream>
#include <string>
#include <map>
int main()
{
std::map<int, std::string> map;
map.insert(std::make_pair(1, "some text"));
auto position = map.find(1);
if (position != map.end() ) {
std::cout << "Found! The key is " << position->first << ", the value is " << position->second << std::endl;
}
}
从键访问值的一个替代方法是使用 at()
,它接受一个键并返回关联的值。
如果没有关联的值,at()
将抛出异常。
获取与键关联的值的最后一个替代方法是使用 operator[]
。
operator[]
返回与键关联的值,如果键不存在,则使用提供的键插入一个新的键/值对,并为值提供一个默认值。因为 operator[]
可能会通过插入来修改映射,所以它不能用于 *const*
映射:
#include <iostream>
#include <map>
int main()
{
std::map<int, int> map;
std::cout << "We ask for a key which does not exists: it is default inserted: " << map[10] << std::endl;
map.at(10) += 100;
std::cout << "Now the value is present: " << map.find(10)->second << std::endl;
}
活动 20:从给定的用户名中检索用户的余额
我们希望能够快速检索给定用户名的用户余额。
为了快速从用户名中检索余额,我们使用用户名作为键,将余额存储在一个映射中。
用户名是 std::string
类型,而余额是 int
类型。为用户 Alice
、Bob
和 Charlie
每人添加 50 的余额。然后,检查用户 Donald
是否有余额。
最后,打印 Alice
的账户余额:
-
包含
map
类的头部文件和string
的头部文件:#include <string> #include <map> #include <string>
-
创建一个键为
std::string
,值为int
的映射。 -
使用
insert
和std::make_pair
将用户的余额插入到映射中。第一个参数是key
,而第二个参数是value
:balances.insert(std::make_pair("Alice",50));
-
使用
find
函数,提供要查找的用户名以找到账户在映射中的位置。将其与end()
进行比较以检查是否找到了位置。 -
现在,查找爱丽丝的账户。我们知道爱丽丝有一个账户,因此没有必要检查我们是否找到了一个有效的位置。我们可以使用
->second
打印账户的值:auto alicePosition = balances.find("Alice"); std::cout << "Alice balance is: " << alicePosition->second << std::endl;
注意
这个活动的解决方案可以在第 305 页找到。
无序容器
无序关联容器与关联容器不同,其元素没有定义的顺序。直观上,无序容器通常被想象成元素的袋子。因为元素没有排序,无序容器不接受比较器对象来为元素提供顺序。另一方面,所有无序容器都依赖于哈希函数。
用户可以将 Functor
(函数对象)作为参数提供,以指定如何对键进行哈希:
图 5.13:展示无序容器及其描述的表
通常,无序容器被实现为 哈希表。数组的定位是通过哈希函数确定的,该函数给定一个值返回它应该存储的位置。理想情况下,大多数元素将被映射到不同的位置,但哈希函数可能会为不同的元素返回相同的位置。这被称为 碰撞。这个问题通过使用链表将映射到相同位置的元素链接起来来解决,这样就可以在同一个位置存储多个元素。因为可能在同一个位置有多个元素,所以这个位置通常被称为 桶。
使用哈希表实现无序容器允许我们在常数时间内找到具有特定值的元素,这比关联容器更快:
图 5.14:当元素被添加到集合中时,计算其哈希值以决定元素应该添加到哪个桶中。桶内的元素存储为列表的节点。
当将键/值对添加到映射中时,计算键的哈希值以决定键/值对应该添加到哪个桶中:
图 5.15:从键计算元素桶的表示,并将键/值对作为列表中的节点存储。
无序关联容器和有序关联容器提供相同的功能,前一小节中的解释也适用于无序关联容器。无序关联容器可以在元素顺序不重要时提供更好的性能。
容器适配器
STL 库提供的附加容器类是容器适配器。容器适配器在我们在本章中查看的容器之上提供了受限的访问策略。
容器适配器有一个模板参数,用户可以提供以指定要包装的容器类型:
图 5.16:展示容器适配器和其描述的表格
栈
栈容器实现了 LIFO 访问策略,其中元素虚拟地堆叠在彼此之上,使得最后插入的元素始终位于顶部。元素只能从顶部读取或移除,因此最后插入的元素是第一个被移除的。栈是通过内部使用序列容器类实现的,用于存储所有元素并模拟栈行为。
栈数据结构的访问模式主要通过三个核心成员函数:push()
、top()
和 pop()
。push()
函数用于将元素插入栈中,top()
用于访问栈顶元素,而 pop()
用于移除栈顶元素。
stack
类在 <stack>
头文件中定义。
队列
queue
类实现了 FIFO 访问策略,其中元素依次入队,因此先插入的元素位于后插入的元素之前。元素在队列末尾插入,在队列开头移除。
队列数据结构的接口由 push()
、front()
、back()
和 pop()
成员函数组成。
push()
函数用于将一个元素插入到 queue()
中;front()
和 back()
分别返回队列的下一个和最后一个元素;pop()
用于从队列中移除下一个元素。
queue
类在 <queue>
头文件中定义。
优先队列
最后,优先队列是一个根据元素的优先级进行访问的队列,按降序排列(优先级最高者先访问)。
接口类似于正常队列,其中 push()
插入新元素,top()
和 pop()
访问和移除下一个元素。不同之处在于确定下一个元素的方式。它不是第一个插入的元素,而是具有最高优先级的元素。
默认情况下,元素的优先级是通过比较元素与operator<
来计算的,因此小于另一个元素的元素会跟在其后。可以提供一个用户定义的排序标准来指定如何根据优先级对队列中的元素进行排序。
优先队列类也在<queue>
头文件中定义。
活动 21:按顺序处理用户注册
当用户注册到我们的网站时,我们需要在当天结束时处理注册表单。
我们希望按注册顺序的相反顺序处理注册:
-
假设注册表单的类已经提供:
struct RegistrationForm { std::string userName; };
-
创建一个
stack
来存储用户。 -
我们希望在用户注册时存储用户注册表单,并在当天结束时处理注册。处理表单的函数已提供:
void processRegistration(RegistrationForm form) { std::cout << "Processing form for user: " << form.userName << std::endl; }
-
此外,当用户注册时,已经有两个函数被调用。
-
在以下两个函数中填写代码以存储用户表单并处理它:
void storeRegistrationForm(std::stack<RegistrationForm>& stack, RegistrationForm form) { } void endOfDayRegistrationProcessing(std::stack<RegistrationForm>& stack) { }
我们将看到,由于用户注册,注册表单是按注册顺序的相反顺序处理的。
注意
此活动的解决方案可以在第 306 页找到。
非常规容器
到目前为止,我们看到了用于存储相同类型元素组的容器。
C++标准定义了一些其他类型,可以包含类型,但它们提供的功能集与之前看到的容器不同。
这些类型如下:
-
字符串
-
对和元组
-
可选
-
变体
字符串
字符串是一种用于操作连续字符的可变序列的数据结构。C++字符串类是 STL 容器:它们的行为类似于向量,但提供了额外的功能,使程序员能够轻松地执行字符序列的常见操作。
标准库中存在几种字符串实现,适用于不同长度的字符集,例如string
、wstring
、u16string
和u32string
。所有这些都是basic_string
基类的特化,并且它们都具有相同的接口。
最常用的类型是std::string
。
所有字符串类型和函数都在<string>
头文件中定义。
字符串可以被转换为空终止字符串,这是一个以特殊空字符(用'\0'
表示)终止的字符数组,通过使用data()
或c_str()
函数实现。空终止字符串,也称为C-字符串,是在 C 语言中表示字符序列的方式,它们在程序需要与 C 库交互时经常被使用;它们以const char *
类型表示,并且是我们程序中字面字符串的类型。
练习 12:演示c_str()
函数的工作机制
让我们检查以下代码以了解c_str()
函数的工作原理:
-
首先按照以下示例包含所需的头文件:
#include <iostream> #include <string>
-
现在,在
main
函数中添加一个名为charString
的常量字符变量,其容量为8
个字符:int main() { // Construct a C-string being explicit about the null terminator const char charString[8] = {'C', '+', '+', ' ', '1', '0', '1', '\0'}; // Construct a C-string from a literal string. The compiler automatically adds the \0 at the end const char * literalString = "C++ Fundamentals"; // Strings can be constructed from literal strings. std::string strString = literalString;
-
使用
c_str()
函数并将strString
的值赋给charString2
:const char *charString2 = strString.c_str();
-
使用打印函数打印
charString
和charString2
:std::cout << charString << std::endl; std::cout << charString2 << std::endl; }
输出如下:
Output: C++ 101 C++ Fundamentals
对于向量而言,字符串有 size()
、empty()
和 capacity()
成员函数,但还有一个额外的函数称为 length()
,它只是 size()
的别名。
可以使用 operator[]
或 at()
、front()
和 back()
成员函数逐字符访问字符串:
std::string chapter = "We are learning about strings";
std::cout << "Length: " << chapter.length() << ", the second character is " << chapter[1] << std::endl;
字符串提供了通常的比较运算符,从而简化了两个字符串对象之间的比较方式。
由于字符串类似于向量,我们可以向它们添加和删除字符。
可以通过分配一个空字符串、调用 clear()
或 erase()
函数来使字符串为空。
让我们看看以下代码,以了解 clear()
和 erase()
函数的用法:
#include <iostream>
#include <string>
int main()
{
std::string str = "C++ Fundamentals.";
std::cout << str << std::endl;
str.erase(5,10);
std::cout << "Erased: " << str << std::endl;
str.clear();
std::cout << "Cleared: " << str << std::endl;
}
Output:
C++ Fundamentals.
Erased: C++ Fs.
Cleared:
C++ 还提供了许多便利函数,可以将字符串转换为数值或将数值转换为字符串。例如,stoi()
和 stod()
函数(分别代表 string-to-int 和 string-to-double)用于将 string
转换为 int
和 double
,相反,要将值转换为字符串,可以使用重载函数 to_string()
。
让我们使用以下代码来揭示这些函数的神秘之处:
#include <iostream>
#include <string>
using namespace std;
int main()
{
std::string str = "55";
std::int strInt = std::stoi(str);
double strDou = std::stod(str);
std::string valToString = std::to_string(strInt);
std::cout << str << std::endl;
std::cout << strInt << std::endl;
std::cout << strDou << std::endl;
std::cout << valToString << std::endl;
}
Output:
55
55
55
55
对象对和元组
pair 和 tuple 类在某种程度上是相似的,因为它们可以存储异构元素集合。
pair 类可以存储两种类型的值,而 tuple 类扩展了这个概念,使其可以存储任意长度的值。
对象对(Pair)定义在 <utility>
头文件中,而元组(tuple)定义在 <tuple>
头文件中。
对象对构造函数接受两个模板参数,用于指定第一个和第二个值的类型。这些元素可以直接使用 first
和 second
数据访问。等效地,这些成员也可以通过 get<0>()
和 get<1>()
函数访问。
便利函数 make_pair()
用于创建一个值对,无需显式指定类型:
std::pair<std::string, int> nameAndAge = std::make_pair("John", 32);
std::cout << "Name: " << nameAndAge.first << ", age: " << nameAndAge.second << std::endl;
第二行等同于以下一行:
std::cout << "Name: " << std::get<0>(nameAndAge) << ", age: " << std::get<1>(nameAndAge) << std::endl;
对象对(Pairs)被无序映射(unordered map)、无序多重映射(unordered multimap)、映射(map)和多重映射(multimap)容器用来管理它们的键/值元素。
元组与对象对类似。构造函数允许你提供可变数量的模板参数。元素只能通过 get<N>()
函数访问,该函数返回元组内的第 n 个元素,并且有一个类似于对象对的便利函数来创建它们,名为 make_tuple()
。
此外,元组还有一个用于从它们中提取值的便利函数。tie()
函数允许创建一个引用元组,这在将元组中的选定元素赋给特定变量时很有用。
让我们了解如何使用make_tuple()
和get()
函数从元组中检索数据:
#include <iostream>
#include <tuple>
#include <string>
int main()
{
std::tuple<std::string, int, float> james = std::make_tuple("James", 7, 1.90f);
std::cout << "Name: " << std::get<0>(james) << ". Agent number: " << std::get<1>(james) << ". Height: " << std::get<2>(james) << std::endl;
}
Output:
Name: James. Agent number: 7\. Height: 1.9
std::optional
optional<T>
是一个用于包含可能存在或不存在值的模板。
该类接受一个模板参数T
,它表示std::optional
模板类可能包含的类型。值类型表示类的实例包含值。复制optional
将创建包含数据的新的副本。
在程序执行的任何时刻,optional<T>
要么为空,要么包含类型为T
的值。
Optional
定义在<optional>
头文件中。
让我们假设我们的应用程序正在使用名为User
的类来管理注册用户。我们希望有一个函数可以从用户的电子邮件中获取用户信息:User getUserByEmail(Email email);
。
但当用户未注册时会发生什么?也就是说,当我们确定我们的系统没有关联的User
实例时?
有些人会建议抛出异常。在 C++中,异常用于异常情况,这些情况几乎永远不会发生。用户未在我们的网站上注册是一个完全正常的情况。
在这些情况下,我们可以使用optional
模板类来表示我们可能没有数据的事实:
std::optional<User> tryGetUserByEmail(Email email);
optional
模板类提供了两种简单的方法来处理:
-
has_value()
: 如果optional
当前持有值,则返回true
,如果变体为空,则返回false
。 -
value()
: 此函数返回optional
当前持有的值,如果不存在则抛出异常。 -
此外,
optional
可以用作if
语句中的条件:如果它包含值,则评估为true
,否则为false
。
让我们通过以下示例来了解has_value()
和value()
函数是如何工作的:
#include <iostream>
#include <optional>
int main()
{
// We might not know the hour. But if we know it, it's an integer
std::optional<int> currentHour;
if (not currentHour.has_value()) {
std::cout << "We don't know the time" << std::endl;
}
currentHour = 18;
if (currentHour) {
std::cout << "Current hour is: " << currentHour.value() << std::endl;
}
}
Output:
We don't know the time
Current hour is: 18
optional
模板类附带了一些额外的便利特性。我们可以将std::nullopt
值赋给optional
,以便在需要使其为空时明确表示,并且可以使用make_optional
值从值创建一个可选对象。此外,我们可以使用解引用运算符*
来访问optional
的值,而无需抛出异常,如果值不存在。在这种情况下,我们将访问无效数据,因此我们需要确保在使用*
时optional
包含一个值:
std::optional<std::string> maybeUser = std::nullopt;
if (not maybeUser) {
std::cout << "The user is not present" << std::endl;
}
maybeUser = std::make_optional<std::string>("email@example.com");
if (maybeUser) {
std::cout << "The user is: " << *maybeUser << std::endl;
}
另一个方便的方法是value_or(defaultValue)
。此函数接受一个默认值,如果optional
当前持有值,则返回optional
包含的值,否则返回默认值。让我们探索以下示例:
#include <iostream>
#include <optional>
int main()
{
std::optional<int> x;
std::cout << x.value_or(10) << std::endl;
//Will return value of x as 10
x = 15;
std::cout << x.value_or(10)<< std::endl;
//Will return value of x as 15
}
Output:
10
15
除了返回值外,optional
在作为参数接受时也很有用,可以表示可能存在或不存在的数据。
让我们回顾一下由电子邮件地址、电话号码和物理地址组成的 User
类。有时,用户没有电话号码,也不希望提供物理地址,因此 User
中唯一必需的字段是电子邮件地址:
User::User(Email email, std::optional<PhoneNumber> phoneNumber = std::nullopt, std::optional<Address> address = std::nullopt){
...
}
此构造函数允许我们传入我们拥有的所有用户信息。如果我们不使用 optional
,而是使用多个重载,我们将有四个重载:
-
仅电子邮件
-
电子邮件和电话号码
-
电子邮件和地址
-
电子邮件、电话号码和地址
你可以看到,当有更多我们可能不想传递的参数时,重载的数量会迅速增加。
std::variant
variant
是一个用于表示 类型选择 的值类型。该类接受一个类型列表,并且 variant
将能够包含这些类型中的任何一个值。
它通常被称为 标签联合体,因为与联合体类似,它可以存储多个类型,但一次只有一个。它还跟踪当前存储的是哪种类型。
在程序执行过程中,variant
将一次只包含可能类型中的一个。
与 optional
类似,variant
是一个值类型:当我们创建 variant
的副本时,当前存储的元素将被复制到新的 variant
中。
要与 std::variant
交互,C++ 标准库为我们提供了两个主要函数:
-
holds_alternative<Type>(variant)
: 如果variant
当前持有提供的类型,则返回true
,否则返回false
。 -
get(variant)
: 有两种版本:get<Type>(variant)
和get<Index>(variant)
。
get<Type>(variant)
获取 variant
内当前存储的类型值。在调用此函数之前,调用者需要确保 holds_alternative<Type>(variant)
返回 true
。
get<Index>(variant)
获取 variant
内当前存储的索引类型值。像之前一样,调用者需要确保 variant
持有正确的类型。
例如,对于 std::variant<string, float> variant
,调用 get<0>(variant)
将给出 string
值,但我们需要确保 variant
当前存储的是字符串。通常,最好使用 get<Type>()
来访问元素,这样我们可以明确地指出我们期望的类型,并且如果 variant
中类型的顺序发生变化,我们仍然会得到相同的结果:
练习 13:在程序中使用 Variant
让我们执行以下步骤来了解如何在程序中使用 variant
:
-
包含所需的头文件:
#include <iostream> #include <variant>
-
在主函数中,添加具有字符串和整数值类型的
variant
:int main() { std::variant<std::string, int> variant = 42;
-
现在通过两个打印语句以不同的方式调用
variant
:std::cout << get<1>(variant) << std::endl; std::cout << get<int>(variant) << std::endl;
输出如下:
Output:
42
42
获取variant
内容的一种替代方法是使用std::visit(visitor, variant)
,它接受variant
和一个可调用对象。可调用对象需要支持operator()
的重载,该重载接受variant
内部可能存储的每种类型的类型。然后,visit
将确保调用接受variant
内部当前存储类型的函数:
练习 14:访问者变体
让我们执行以下步骤来了解如何在程序中使用std::visit(visitor, variant)
:
-
在程序开始处添加以下头文件:
#include <iostream> #include <string> #include <variant>
-
现在,添加如所示的结构体
Visitor
:struct Visitor { void operator()(const std::string& value){ std::cout << "a string: " << value << std::endl; } void operator()(const int& value){ std::cout << "an int: " << value << std::endl; } };
-
现在,在主函数中,调用结构体
Visitor
并按如下所示传递值:int main() { std::variant<std::string, int> variant = 42; Visitor visitor; std::cout << "The variant contains "; std::visit(visitor, variant); variant = std::string("Hello world"); std::cout << "The variant contains "; std::visit(visitor, variant); }
输出如下:
The variant contains an int: 42
The variant contains a string: Hello world
当我们想要表示一组不同类型的数据时,variant
非常有用。以下是一些典型示例:
-
根据程序当前状态返回不同类型的函数
-
表示多个状态的一个类
让我们想象我们之前描述的std::optional<User> tryGetUserByEmail()
函数。
多亏了optional
,我们现在可以以清晰的方式编写函数,表明有时我们可能不会检索到用户。如果用户未注册,我们可能会询问他们是否想要注册。
让我们想象我们有一个struct UserRegistrationForm
,它包含让用户注册所需的信息。
我们的功能现在可以返回std::variant<User, UserRegistrationForm> tryGetUserByEmail()
。当用户已注册时,我们返回User
,但如果用户未注册,我们可以返回注册表单。
此外,当出现错误时,我们应该怎么做?使用variant
,我们可以有struct GetUserError
存储所有信息,以便我们的应用程序能够从错误中恢复并添加到返回类型:std::variant<User, UserRegistrationForm, GetUserError>
或tryGetUserByEmail()
。
现在,我们只需查看函数签名,就可以看到调用getUserByEmail()
时将要发生的事情的完整情况,编译器将帮助我们确保处理所有情况。
或者,variant
也可以用来表示一个类可能处于的各种状态。每个状态包含该状态所需的数据,而类只管理从一个状态到另一个状态的转换。
活动 22:机场系统管理
让我们编写一个程序来创建机场系统管理:
-
我们想在机场系统中表示飞机的状态。飞机可以在三种状态中:
at_gate
、taxi
或flying
。三种状态存储不同的信息。 -
使用
at_gate
,飞机存储其所在的登机口编号。使用taxi
,我们存储分配给飞机的航站楼车道以及机上的乘客数量。使用flying
,我们存储速度:struct AtGate { int gate; }; struct Taxi { int lane; int numPassengers; }; struct Flying { float speed; };
-
飞机应该有三个方法:
-
startTaxi()
: 此方法接受飞机应行驶的航向和机上的乘客数量。飞机只有在位于登机口时才能开始滑行。 -
takeOff()
: 此方法接受飞机应飞行的速度。飞机只有在处于滑行状态时才能开始飞行。 -
currentStatus()
: 此方法打印飞机的当前状态。注意
此活动的解决方案可以在第 306 页找到。
-
迭代器
在本章中,我们多次提到元素在容器中有一个位置:例如,我们说我们可以在列表的特定位置插入一个元素。
迭代器是表示容器中元素位置的方式。
它们提供了一种一致的方式来操作容器中的元素,抽象出元素所属容器的细节。
迭代器始终属于一个范围。表示范围开始的迭代器可以通过begin()
函数访问,而表示范围结束的迭代器(非包含),可以通过end()
函数获得。第一个元素包含但最后一个元素排除的范围被称为半开区间。
迭代器必须提供接口由四个函数组成:
-
*
运算符提供了访问迭代器当前引用位置的元素的方法。 -
++
运算符用于向前移动到下一个元素。 -
然后,使用
==
运算符来比较两个迭代器,以检查它们是否指向相同的位置。注意,只有当两个迭代器属于同一范围时,它们才能进行比较:它们必须代表同一容器中元素的位置。
-
最后,使用
=
运算符来分配迭代器。
在 C++中,每个容器类都必须指定它提供的迭代器类型,作为名为iterator
的成员类型别名。例如,对于整数向量,类型将是std::vector<int>::iterator
。
让我们看看我们如何可以使用迭代器遍历容器(在这种情况下是向量)的所有元素:
#include <iostream>
#include <vector>
int main()
{
std::vector<int> numbers = {1, 2, 3};
for(std::vector<int>::iterator it = numbers.begin(); it != numbers.end(); ++it) {
std::cout << "The number is: " << *it << std::endl;
}
}
对于这样的操作,这看起来很复杂,我们在第一章,入门中看到了我们如何使用基于范围的 for 循环:
for(int number: numbers) {
std::cout << "The number is: " << number << std::endl;
}
基于范围的 for 循环之所以能够工作,是因为迭代器:编译器将我们的基于范围的 for 循环重写为看起来像我们使用迭代器编写的那样。这使得基于范围的 for 循环可以与任何提供begin()
和end()
函数并返回迭代器的类型一起工作。
迭代器提供的操作符的实现方式取决于迭代器操作的容器。
迭代器可以分为四类。每一类都是基于前一类构建的,从而提供额外的功能:
图 5.17:展示迭代器和其描述的表格
以下图表提供了关于 C++迭代器的更多详细信息:
图 5.18:C++ 中迭代器层次结构的表示
让我们更详细地了解这些迭代器:
-
使用
==
和!=
运算符来检查迭代器是否等于end()
值。通常,输入迭代器用于从元素流中访问元素,其中整个序列没有存储在内存中,但我们一次获取一个元素。
-
前向迭代器与输入迭代器非常相似,但提供了额外的保证。
同一个迭代器可以被多次解引用以访问它指向的元素。
此外,当我们递增或解引用前向迭代器时,其他副本不会被无效化:如果我们复制了一个前向迭代器,我们可以前进第一个,第二个仍然可以用来访问前一个元素。
指向相同元素的两个迭代器保证是相等的。
-
operator--
(位置减量)成员函数。 -
使用
operator[]
成员函数来访问泛型索引处的元素,以及使用二进制operator+
和operator-
来前进和后退任何数量。
练习 15:探索迭代器
执行以下步骤来探索上一节中讨论的四个类别,并将值写入它指向的元素,它也是一个输出迭代器:
-
在程序开始处添加以下头文件:
#include <iostream> #include <vector>
-
在主函数中声明名为 number 的向量:
int main() { std::vector<int> numbers = {1, 2, 3, 4, 5}; auto it = numbers.begin();
-
执行如图所示的各项算术运算:
std::cout << *it << std::endl; // dereference: points to 1 it++; // increment: now it points to 2 std::cout << *it << std::endl; // random access: access the 2th element after the current one std::cout << it[2] << std::endl; --it; // decrement: now it points to 1 again std::cout << *it << std::endl; it += 4; // advance the iterator by 4 positions: now it points to 5 std::cout << *it << std::endl; it++; // advance past the last element; std::cout << "'it' is after the past element: " << (it == numbers.end()) << std::endl; }
输出如下:
1
2
4
1
5
'it' is after the past element: 1
我们将要讨论的许多迭代器都定义在 <iterator>
头文件中。
反向迭代器
有时候,我们需要以相反的顺序遍历元素集合。
C++ 提供了一个迭代器,允许我们这样做:反向迭代器。
一个 反向迭代器 包装一个 双向迭代器,并交换增量操作与减量操作,反之亦然。
由于这个原因,当我们正向迭代反向迭代器时,我们是在反向顺序访问范围内的元素。
我们可以通过在容器上调用以下方法来反转容器的范围:
图 5.19:表示迭代器函数及其描述的表格
在正常迭代器上工作的代码,也可以与反向迭代器一起工作。
例如,我们可以看到代码如何与反向顺序迭代相似。
练习 16:探索反向迭代器的功能
让我们执行以下步骤来了解反向迭代器中函数的工作方式:
-
在程序开始处添加以下头文件:
#include <iostream> #include <vector>
-
在主函数中,如图所示添加名为 numbers 的向量:
int main() { std::vector<int> numbers = {1, 2, 3, 4, 5};
-
现在如图所示遍历 number 向量:
for(auto rit = numbers.rbegin(); rit != numbers.rend(); ++rit) { std::cout << "The number is: " << *rit << std::endl; } }
输出如下:
The number is: 5
The number is: 4
The number is: 3
The number is: 2
The number is: 1
插入迭代器
插入迭代器,也称为插入器,用于将新值插入到容器中而不是覆盖它们。
存在三种类型的插入器,它们在容器中插入元素的位置上有所不同。
下表总结了不同的类别:
图 5.20:表示迭代器函数及其描述的表格
一些算法,我们将在本章后面看到,需要迭代器来存储数据。插入迭代器通常与这些算法一起使用。
流迭代器
Stream iterators
允许我们将流用作读取元素的源或写入元素的目的地:
图 5.21:表示迭代器函数及其描述的表格
由于在这种情况下我们没有容器,我们无法调用 end()
方法来获取 end
迭代器。默认构造的流迭代器被视为任何流范围的末尾。
让我们看看一个从标准输入读取空格分隔整数的程序。
练习 17:流迭代器
让我们执行以下步骤来了解反向流函数的工作原理:
-
添加如图所示的所需头文件:
#include <iostream> #include <iterator>
-
现在,在主函数中,添加如图所示的 istream 迭代器:
int main() { std::istream_iterator<int> it = std::istream_iterator<int>(std::cin); std::istream_iterator<int> end; for(; it != end; ++it) { std::cout << "The number is: " << *it << std::endl; } }
输出如下(输入:10):
The number is: 10
迭代器无效化
正如我们所说的,迭代器表示容器中元素的位置。
这意味着它们与容器紧密相关,对容器的更改可能会移动元素:这意味着指向此类元素的迭代器将无法再使用——它们被无效化了。
在使用容器和迭代器时,始终检查无效化合同非常重要,因为未指定使用无效迭代器时会发生什么。更常见的是,访问无效数据或程序崩溃,导致难以找到的 bug。
如果我们记住容器是如何实现的,就像我们在本章前面看到的,我们就可以更容易地记住何时迭代器被无效化。
例如,我们说过,当我们向向量中插入一个元素时,我们可能需要更多的内存来存储该元素,在这种情况下,所有前面的元素都需要移动到新获得的内存中。这意味着所有指向元素的迭代器现在都指向了元素的老位置:它们被无效化了。
另一方面,我们看到了当我们向列表中插入一个元素时,我们只需要更新前驱和后继节点,但元素本身并不移动。这意味着指向元素的迭代器仍然有效:
#include <iostream>
#include <vector>
#include <list>
int main()
{
std::vector<int> vector = {1};
auto first_in_vec = vector.begin();
std::cout << "Before vector insert: " << *first_in_vec << std::endl;
vector.push_back(2);
// first_number is invalidated! We can no longer use it!
std::list<int> list = {1};
auto first_in_list = list.begin();
list.push_back(2);
// first_in_list is not invalidated, we can use it.
std::cout << "After list insert: " << *first_in_list << std::endl;
}
Output:
Before vector insert: 1
After list insert: 1
当需要存储指向元素的迭代器时,在决定使用哪个容器时,迭代器无效化是一个重要的考虑因素。
练习 18:打印所有客户的余额
我们希望打印我们应用程序中所有客户的余额。余额已经以整数的形式存储在向量中。
我们想使用迭代器遍历余额向量。按照以下步骤进行:
-
初始时,我们包含
vector
类的头文件,并声明一个包含 10 个int
类型元素的向量:#include <vector> std::vector<int> balances = {10, 34, 64, 97, 56, 43, 50, 89, 32, 5};
-
for
循环已被修改为使用向量的迭代器迭代,从begin()
返回的位置开始,直到它达到end()
返回的位置:for (auto pos = numbers.begin(); pos != numbers.end(); ++pos) { // to be filled }
-
使用迭代器的解引用运算符(
*
)访问数组元素:for (auto pos = numbers.begin(); pos != numbers.end(); ++pos) { std::cout << "Balance: " << *pos << std::endl; }
C++标准模板库提供的算法
算法是以抽象方式操作容器的一种方式。
C++标准库为可以在元素范围上执行的所有常见操作提供了一组广泛的算法。
因为算法接受迭代器,所以它们可以操作任何容器,甚至是用户定义的容器,只要它们提供迭代器。
这允许我们拥有大量算法,这些算法可以与大量容器一起工作,而无需算法知道容器是如何实现的。
以下是一些由 STL 提供的最重要和最常用的算法。
注意
算法操作范围,因此它们通常接受一对迭代器:first和last。
正如我们在本章前面所说的,last迭代器表示范围结束之后的元素——它不是范围的一部分。
这意味着当我们想要操作一个完整的容器时,我们可以将begin()
和end()
作为参数传递给算法,但如果我们想要操作一个较短的序列,我们必须确保我们的最后一个迭代器已经超过了我们想要包含在范围内的最后一个项目。
Lambda
大多数算法接受一个一元或二元谓词:一个Functor
(函数对象),它接受一个或两个参数。这些谓词允许用户指定算法所需的一些操作。这些操作因算法而异。
正如我们在第三章,类的结尾所看到的,要编写一个函数对象,我们必须创建一个类并重载operator()
。
这可能非常冗长,尤其是当函数对象应该执行简单操作时。
为了用 C++克服这个问题,用户必须编写一个lambda 表达式,也称为lambda。
一个lambda 表达式创建了一个特殊的函数对象,其类型只有编译器知道,它表现得像一个函数,但可以访问它创建的作用域中的变量。
它使用与函数非常相似的语法定义:
[captured variables] (arguments) { body }
这创建了一个新对象,当使用 lambda 表达式中指定的参数调用时,执行函数体。
参数是函数接受的参数列表,主体是当函数被调用时要执行的语句序列。它们与函数具有相同的含义,并且适用于函数的相同规则我们在第二章,函数中看到。
例如,让我们创建一个接受两个整数并返回它们的和的 lambda:
#include <iostream>
int main()
{
auto sum_numbers = [] (int a, int b) { return a + b; };
std::cout << sum_numbers(10, 20) << std::endl;
}
Output:
30
默认情况下,lambda 函数体的作用域只能引用在参数列表和函数体内部定义的变量,就像函数一样。
此外,lambda可以捕获局部作用域中的变量,并在其函数体中使用它。
捕获的变量包含可以在 lambda 函数体中引用的变量名列表。
当一个变量被捕获时,它被存储在创建的函数对象内部,并且可以在函数体中引用。
默认情况下,变量是通过值捕获的,因此它们被复制到函数对象内部:
#include <iostream>
int main()
{
int addend = 1;
auto sum_numbers = addend { return addend + b; };
addend = 2;
std::cout << sum_numbers(3) << std::endl;
}
Output:
4
当我们创建 lambda 时,我们通过值捕获了addend
:它被复制到sum_numbers
对象中。即使我们修改了addend
的值,我们也没有改变存储在sum_numbers
内部的副本,所以当sum_numbers
执行时,它将 1 加到b
上。
在某些情况下,我们希望能够在创建 lambda 的作用域中修改变量的值,或者我们希望访问实际的值,而不是 lambda 创建时变量的值。
在那种情况下,我们可以通过在变量名前加上&
来通过引用捕获。
注意
当我们通过引用捕获时,我们需要确保在 lambda 被调用时,被引用捕获的变量仍然有效,否则函数体的作用域访问了一个无效的对象,导致错误。如果可能,最好通过值捕获。
让我们看看一个例子:
#include <iostream>
int main()
{
int multiplier = 1;
auto multiply_numbers = &multiplier { return multiplier * b; };
multiplier = 2;
std::cout << multiply_numbers(3) << std::endl;
}
Output:
6
这里,我们通过引用捕获了multiplier
变量:只有它的引用被存储到multiply_numbers
中。
当我们调用multiply_numbers
时,函数体访问multiplier
的当前值,由于multiplier
被改为 2,这就是lambda使用的值。
lambda 可以捕获多个变量,每个变量可以是独立于其他变量的值捕获或引用捕获。
只读算法
只读算法是检查容器内存储的元素但不修改容器元素顺序的算法。
以下是最常见的检查范围元素的操作:
图 5.22:展示检查范围元素操作的表格
让我们看看我们如何使用这些函数:
#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> vector = {1, 2, 3, 4};
bool allLessThen10 = std::all_of(vector.begin(), vector.end(), [](int value) { return value < 10; });
std::cout << "All are less than 10: " << allLessThen10 << std::endl;
bool someAreEven = std::any_of(vector.begin(), vector.end(), [](int value) { return value % 2 == 0; });
std::cout << "Some are even: " << someAreEven << std::endl;
bool noneIsNegative = std::none_of(vector.begin(), vector.end(), [](int value) { return value < 0; });
std::cout << "None is negative: " << noneIsNegative << std::endl;
std::cout << "Odd numbers: " << std::count_if(vector.begin(), vector.end(), [](int value) { return value % 2 == 1; }) << std::endl;
auto position = std::find(vector.begin(), vector.end(), 6);
std::cout << "6 was found: " << (position != vector.end()) << std::endl;
}
Output:
All are less than 10: 1
Some are even: 1
None is negative: 1
Odd numbers: 2
6 was found: 0
修改算法
修改算法是修改它们迭代的集合的算法:
图 5.23:展示修改算法的表格
让我们看看这些算法的实际应用:
#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>
int main()
{
std::vector<std::string> vector = {"Hello", "C++", "Morning", "Learning"};
std::vector<std::string> longWords;
std::copy_if(vector.begin(), vector.end(), std::back_inserter(longWords), [](const std::string& s) { return s.length() > 3; });
std::cout << "Number of longWords: " << longWords.size() << std::endl;
std::vector<int> lengths;
std::transform(longWords.begin(), longWords.end(), std::back_inserter(lengths), [](const std::string& s) { return s.length(); });
std::cout << "Lengths: ";
std::for_each(lengths.begin(), lengths.end(), [](int length) { std::cout << length << " "; });
std::cout << std::endl;
auto newLast = std::remove_if(lengths.begin(), lengths.end(), [](int length) { return length < 7; });
std::cout << "No element removed yet: " << lengths.size() << std::endl;
// erase all the elements between the two iterators
lengths.erase(newLast, lengths.end());
std::cout << "Elements are removed now. Content: ";
std::for_each(lengths.begin(), lengths.end(), [](int length) { std::cout << length << " "; });
std::cout << std::endl;
}
Output:
Number of longWords: 3
Lengths: 5 7 8
No element removed yet: 3
Elements are removed now. Content: 7 8
修改算法
修改算法是改变元素顺序的算法:
图 5.24:展示修改算法的表格
让我们看看我们如何使用它们:
#include <iostream>
#include <random>
#include <vector>
#include <algorithm>
#include <iterator>
int main()
{
std::vector<int> vector = {1, 2, 3, 4, 5, 6};
std::random_device randomDevice;
std::mt19937 randomNumberGenerator(randomDevice());
std::shuffle(vector.begin(), vector.end(), randomNumberGenerator);
std::cout << "Values: ";
std::for_each(vector.begin(), vector.end(), [](int value) { std::cout << value << " "; });
std::cout << std::endl;
}
Output:
Values: 5 2 6 4 3 1
排序算法
这类算法以特定顺序重新排列容器内元素的顺序:
图 5.25:展示排序算法的表格
下面是如何对向量进行排序的方法:
#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> vector = {5, 2, 6, 4, 3, 1};
std::sort(vector.begin(), vector.end());
std::cout << "Values: ";
std::for_each(vector.begin(), vector.end(), [](int value) { std::cout << value << " "; });
std::cout << std::endl;
}
Output:
Values: 1 2 3 4 5 6
二分搜索算法
以下表格解释了binary_search
的使用:
图 5.26:展示binary_search
使用的表格
这就是如何利用二分搜索算法的方法:
#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> vector = {1, 2, 3, 4, 5, 6};
bool found = std::binary_search(vector.begin(), vector.end(), 2);
std::cout << "Found: " << found << std::endl;
}
Output:
Found: 1
数值算法
这类算法以不同的方式使用线性运算组合数值元素:
图 5.27:展示数值算法的表格
让我们看看我们如何在以下程序中使用accumulate
:
#include <iostream>
#include <vector>
#include <algorithm>
int main()
{
std::vector<int> costs = {1, 2, 3};
int budget = 10;
int margin = std::accumulate(costs.begin(), costs.end(), budget, [](int a, int b) { return a - b; });
std::cout << "Margin: " << margin << std::endl;
}
Output:
Margin: 4
练习 19:客户分析
我们有我们应用程序许多客户的详细信息,并希望计算这些信息上的分析数据。
给定一个以用户名作为键和用户账户作为值的映射,我们希望按降序打印新用户的余额。
如果用户在 15 天内注册,则被视为新用户。提供表示用户账户的结构如下:
struct UserAccount {
int balance;
int daysSinceRegistered;
};
编写void computeAnalytics(std::map<std::string, UserAccount>& accounts)
函数,该函数打印所需的余额。
-
确保包含解决方案所需的全部标题:
#include <iostream> #include <vector> #include <iterator> #include <map> #include <algorithm>
-
首先,我们需要从映射中提取
UserAccount
。记住,映射存储的元素是包含键和值的pair
。由于我们需要将类型转换为UserAccount
,我们可以使用std::transform
,通过传递一个只返回用户账户的lambda
。为了将其插入到vector
中,我们可以使用std::back_inserter
。确保在 lambda 中接受pair
时使用const
引用:void computeAnalytics(std::map<std::string, UserAccount>& accounts) { // Balance of accounts newer than 15 days, in descending order std::vector<UserAccount> newAccounts; std::transform(accounts.begin(), accounts.end(), std::back_inserter(newAccounts), [](const std::pair<std::string, UserAccount>& user) { return user.second; }); }
-
在我们从
vector
中提取账户后,我们可以使用remove_if
来移除所有超过 15 天的账户:auto newEnd = std::remove_if(newAccounts.begin(), newAccounts.end(), [](const UserAccount& account) { return account.daysSinceRegistered > 15; } ); newAccounts.erase(newEnd, newAccounts.end());
-
在移除旧账户后,我们需要按降序排序余额。默认情况下,
std::sort
使用升序,因此我们需要提供一个lambda
来改变顺序:std::sort(newAccounts.begin(), newAccounts.end(), [](const UserAccount& lhs, const UserAccount& rhs) { return lhs.balance > rhs.balance; } ); Now that the data is sorted, we can print it: for(const UserAccount& account : newAccounts) { std::cout << account.balance << std::endl; } }
-
我们现在可以使用以下测试数据调用我们的函数:
int main() { std::map<std::string, UserAccount> users = { {"Alice", UserAccount{500, 15}}, {"Bob", UserAccount{1000, 50}}, {"Charlie", UserAccount{600, 17}}, {"Donald", UserAccount{1500, 4}} }; computeAnalytics(users); }
概述
在本章中,我们介绍了顺序容器——其元素可以按顺序访问的容器。我们研究了array
、vector
、deque
、list
和forward_list
顺序容器。
我们看到了它们提供的功能以及如何操作它们,我们还看到了它们的实现以及 vector 和 list 的存储方式。
我们接着介绍了关联容器,这些容器允许快速查找其元素,并且始终保持有序。Set
、multiset
、map
和multimap
属于这一类别。
我们研究了它们支持的操作以及如何使用 map 和 multimap 将值关联到键。我们还看到了它们的无序版本,它不保持元素顺序但提供更高的性能。Unordered_set
和unordered_map
属于这一类别。
最后,我们探讨了非传统的容器。String
用于操作字符序列,pair
和 tuple
用于存储不同类型的不同元素,optional
用于向类型添加可选性,而 variant
用于存储可能属于几种类型的值。
然后,我们探讨了迭代器,并学习了它们是如何用于抽象容器概念并提供一组通用功能的。
我们研究了各种迭代器的类型,并了解了迭代器失效的概念及其重要性。
在解释了 lambda
是定义一个可以访问其创建作用域中变量的函数的便捷方式之后,我们最终转向了 C++ 标准中的算法。
我们将最常见的算法分为各种类别,并研究了这些类别中最重要的算法,包括 find
、remove
和 sort
。
在下一章中,你将学习如何使用 C++ 的高级特性来创建动态程序。
第七章:第六章
面向对象编程
课程目标
到本章结束时,你将能够:
-
组合继承其他类属性的其他类
-
在 C++ 程序中实现多态。
-
实现接口
-
使用最佳实践来管理动态内存
在本章中,你将学习如何使用 C++ 的高级特性来创建动态程序。
简介
在前面的章节中,我们学习了模板,它用于创建与任意类型一起工作的函数和类。这避免了工作重复。然而,在所有情况下使用模板并不适用,或者可能不是最佳方法。模板的限制是它们的类型需要在代码编译时已知。
在现实世界的案例中,这并不总是可能的。一个典型的例子是程序根据配置文件中的值确定要使用什么日志基础设施。
考虑以下问题:
-
在开发和执行测试应用程序时,应用程序会使用一个打印详细信息的记录器。
-
另一方面,当应用程序部署到用户的 PC 上时,应用程序会使用一个打印错误摘要并通知开发者的记录器,如果有任何错误。
我们可以使用 C++ 继承的概念来解决这些问题。
继承
继承允许组合一个或多个类。让我们看看继承的一个例子:
class Vehicle {
public:
TankLevel getTankLevel() const;
void turnOn();
};
class Car : public Vehicle {
public:
bool isTrunkOpen();
};
在这个例子中,Car
类继承自 Vehicle
类,或者说 Car
从 Vehicle
继承。在 C++ 术语中,Vehicle
是基类,而 Car
是派生类。
当定义一个类时,我们可以通过在后面跟一个冒号 :
,然后跟一个或多个用逗号分隔的类来指定它继承的类:
class Car : public Vehicle, public Transport {
}
当指定要继承的类列表时,我们还可以指定继承的可见性 – private
、protected
或 public
。
可见性修饰符指定谁可以了解类之间的继承关系。
基类的方法可以根据以下规则作为派生类的方法访问:
Car car;
car.turnOn();
当继承是 public
时,类外部的代码知道 Car
继承自 Vehicle
。基类的所有公共方法都可以作为派生类的公共方法被程序中的代码访问。基类的受保护方法可以通过派生类的方法作为受保护访问。当继承是 protected
时,所有公共和受保护成员都可以作为派生类的受保护访问。只有派生类及其从它派生的类知道关于继承的信息;外部代码看到这两个类是无关的。
最后,当使用 private
修饰符进行继承时,基类的所有 public
和 protected
方法字段都可以由派生类作为 private
访问。
一个类的私有方法和字段在该类外部永远不可访问。
访问基类的字段遵循相同的规则。
让我们看看一个总结:
图 6.1:基类方法和它们提供的访问级别
继承创建了一个派生类和基类的层次结构。
Orange
类可以从 Citrus
类派生,而 Citrus
类又从 Fruit
类派生。以下是它的写法:
class Fruit {
};
class Citrus: public Fruit {
};
class Orange: public Citrus {
};
类 Citrus
可以访问类 Fruit
的公共和受保护方法,而类 Orange
将能够访问 Citrus
和 Fruit
的公共和受保护方法(Fruit
的公共方法可以通过 Citrus
访问)。
练习 20:创建一个演示 C++ 继承的程序
让我们进行以下练习,创建一个从多个基类继承的派生类:
-
在程序开始处添加头文件:
#include <iostream>
-
添加第一个基类,命名为
Vehicle
:// first base class class Vehicle { public: int getTankCapacity(){ const int tankLiters = 10; std::cout << "The current tank capacity for your car is " << tankLiters << " Liters."<<std::endl; return tankLiters; } };
-
现在添加第二个基类,命名为
CollectorItem
:// second base class class CollectorItem { public: float getValue() { return 100; } };
-
添加名为
Ferrari250GT
的派生类,如图所示:// Subclass derived from two base classes class Ferrari250GT: protected Vehicle, public CollectorItem { public: Ferrari250GT() { std::cout << "Thank you for buying the Ferrari 250 GT with tank capacity " << getTankCapacity() << std::endl; return 0; } };
-
现在,在
main
函数中,实例化Ferrari250GT
类并调用getValue()
方法:int main() { Ferrari250GT ferrari; std::cout << "The value of the Ferrari is " << ferrari.getValue() << std::endl; /* Cannot call ferrari.getTankCapacity() because Ferrari250GT inherits from Vehicle with the protected specifier */ return 0; }
输出将如下所示:
Output: The current tank capacity for your car is 10 Liters. Thank you for buying the Ferrari 250 GT with tank capacity 10 The value of the Ferrari is 100
指定符不是强制的。如果省略,则对于结构体默认为 public,对于类默认为 private。
注意
如果你使用继承来在实现类时组合一些功能,通常正确的是使用私有继承,因为这是你实现类的细节,并且它不是类的一部分公共接口。相反,如果你想编写一个可以作为基类使用的派生类,请使用公共继承。
当从类继承时,基类会被嵌入到派生类中。这意味着基类的所有数据也成为了派生类在它的内存表示中的一部分:
图 6.2:派生类和基类的表示
在这一点上可能会出现一个问题——我们在派生类中嵌入基类。这意味着我们需要在初始化派生类时初始化基类,否则类的一部分将保持未初始化状态。我们何时初始化基类?
在编写派生类的构造函数时,编译器会在任何初始化之前隐式调用基类的默认构造函数。
如果基类没有默认构造函数,但有一个接受参数的构造函数,那么派生类的构造函数可以在初始化列表中显式调用它。否则,将出现错误。
与编译器在构造派生类时调用基类的构造函数类似,编译器会在派生类的析构函数运行后始终调用基类的析构函数:
class A {
public:
A(const std::string& name);
};
class B: public A {
public:
B(int number) : A("A's name"), d_number(number) {}
private:
int d_number;
};
}
当调用B
类的构造函数时,需要初始化A
。由于A
没有默认构造函数,编译器无法为我们初始化它:我们必须显式调用A
类的构造函数。
编译器生成的复制构造函数和赋值运算符负责调用基类的构造函数和运算符。
当我们编写自己的复制构造函数和赋值运算符的实现时,我们需要注意调用复制构造函数和赋值运算符。
注意
在许多编译器中,你可以启用额外的警告,这些警告会在你忘记添加对基构造函数的调用时通知你。
重要的是要理解,继承需要模型一个A
从另一个类B
继承,你是在说A
是B
。
为了理解这一点,一个车辆是一个很好的例子:汽车是一种车辆,公共汽车是一种车辆,卡车也是一种车辆。一个不好的例子是汽车继承自发动机。虽然发动机可能具有与汽车相似的功能,例如start
方法,但说汽车是发动机是不正确的。在这种情况下,关系是has a:汽车有一个发动机;这种关系表示组合。
注意
使用is a测试来理解是否可以使用继承来表示关系在某些情况下可能会失败:例如,一个正方形继承自一个矩形。当矩形的宽度加倍时,矩形的面积加倍,但正方形的面积会四倍增加。这意味着当使用正方形时,预期与矩形交互的代码可能会得到令人惊讶的结果,即使从数学上讲,正方形是一个矩形。
一个更一般的规则是,如果A
类继承自B
类,我们可以在B
类被使用的地方替换A
类,代码仍然可以正确运行。
到目前为止,我们已经看到了单重继承的例子:派生类有一个基类。C++支持多重继承:一个类可以继承自多个类。让我们看看一个例子:
struct A {
};
struct B {
};
struct C : A, B {
};
在这个例子中,C
结构体同时从A
和B
继承。
继承如何工作的规则对单重继承和多重继承是相同的:所有派生类的所有方法都基于指定的可见性访问权限可见,我们需要确保为所有基类调用适当的构造函数和赋值运算符。
注意
通常最好有一个浅层继承层次结构:不应该有太多的派生类层次。
当使用多层继承层次结构或多重继承时,你可能会遇到一些问题,例如模糊调用。
当编译器无法清楚地理解要调用哪个方法时,调用是模糊的。让我们看看以下示例:
struct A {
void foo() {}
};
struct B {
void foo() {}
};
struct C: A, B {
void bar() { foo(); }
};
在这个例子中,不清楚要调用哪个foo()
,是A
类的还是B
类的。我们可以通过在类名前加上两个冒号来消除歧义:A::foo()
。
练习 21:使用多重继承创建“欢迎加入社区”消息应用程序
让我们使用多重继承来创建一个打印“欢迎加入社区”消息的应用程序:
-
首先,在程序中添加所需的头文件,如图所示:
#include <iostream>
-
现在,添加所需的类
DataScienceDev
和FutureCppDev
以及所需的打印语句:class DataScienceDev { public: DataScienceDev(){ std::cout << "Welcome to the Data Science Developer Community." << std::endl; } }; class FutureCppDev { public: FutureCppDev(){ std::cout << "Welcome to the C++ Developer Community." << std::endl; } };
-
现在,添加如上图所示的
Student
类:class Student : public DataScienceDev, public FutureCppDev { public: Student(){ std::cout << "Student is a Data Developer and C++ Developer." << std::endl; } };
-
现在,在
main
函数中调用Student
类:int main(){ Student S1; return 0; }
输出将如下所示:
Welcome to the Data Science Developer Community. Welcome to the C++ Developer Community. Student is a Data Developer and C++ Developer.
活动 23:创建游戏角色
我们想编写一个新游戏,并在该游戏中创建两种类型的角色——英雄和敌人。敌人可以挥舞剑,而英雄可以施展法术。
下面是如何完成任务的方法:
-
创建一个具有公共方法
moveTo
的Character
类,该方法打印Moved to position
。 -
创建一个
Position
结构体:struct Position { std::string positionIdentifier; };
-
创建两个类,
Hero
和Enemy
,它们从Character
类派生:class Hero : public Character { }; class Enemy : public Character { };
-
创建一个具有接受法术名称构造函数的
Spell
类:class Spell { public: Spell(std::string name) : d_name(name) {} std::string name() const { return d_name; } private: std::string d_name; }
-
Hero
类应该有一个public
方法来施展法术。使用Spell
类的值。 -
Enemy
类应该有一个public
方法来挥舞剑,这将打印Swinging sword
。 -
实现主方法,该方法在各个类中调用这些方法:
int main() { Position position{"Enemy castle"}; Hero hero; Enemy enemy; }
输出将如下所示:
Moved to position Enemy castle Moved to position Enemy castle Casting spell fireball Swinging sword
注意
这个活动的解决方案可以在第 309 页找到。
多态性
在前面的章节中,我们提到继承是一种允许你在程序运行时更改代码行为的解决方案。这是因为继承使 C++具有多态性。
多态性意味着多种形式,表示对象能够以不同的方式行为。
我们之前提到,模板是在编译时与许多不同类型一起工作的代码的方式,并且根据用于实例化模板的类型,行为将发生变化。
这种模式被称为静态多态性——静态是因为它在编译时已知。C++也支持动态多态性——在程序运行时方法的行为可以改变。这很强大,因为我们可以在编译程序之后仅对获得的信息做出反应,例如用户输入、配置中的值或代码运行的硬件类型。这要归功于两个特性——动态绑定和动态分派。
动态绑定
动态绑定是指基类型引用或指针在运行时指向派生类型对象的能力。让我们探索以下示例:
struct A {
};
struct B: A{
};
struct C: A {
};
//We can write
B b;
C c;
A& ref1 = b;
A& ref2 = c;
A* ptr = nullptr;
if (runtime_condition()) {
ptr = &b;
} else {
ptr = &c;
}
注意
为了允许动态绑定,代码必须知道派生类从基类派生。
如果继承的可见性是private
,则只有派生类中的代码能够将对象绑定到基类的指针或引用。
如果继承是protected
,那么派生类以及从它派生的所有类都将能够执行动态绑定。最后,如果继承是public
,动态绑定将始终被允许。
这在static
类型和dynamic
(或运行时)类型之间创建了区别。静态类型是我们可以在源代码中看到的类型。在这种情况下,我们可以看到ref1
的静态类型是A
结构的引用。
动态类型是对象的实际类型:在运行时在对象的内存位置上构建的类型。例如,ref1
和ref2
的静态类型都是A
结构的引用,但ref1
的动态类型是B
,因为ref1
指向一个在内存位置上创建了类型为B
的对象的内存位置,而ref2
的动态类型是C
,原因相同。
正如所说,动态类型可以在运行时改变。虽然变量的静态类型始终相同,但其动态类型可以改变:ptr
有一个静态类型,即指向A
的指针,但它的动态类型在程序执行过程中可能会改变:
A* ptr = &b; // ptr dynamic type is B
ptr = &c; // ptr dynamic type is now C
重要的是要理解,只有引用和指针可以从派生类安全地赋值。如果我们将对象赋值给值类型,我们会得到一个令人惊讶的结果 - 对象会被切割。
我们之前说过,基类是嵌入在派生类中的。比如说,如果我们尝试赋值,就像这样:
B b;
A a = b;
代码将编译,但只有B
中嵌入的A
的部分将被复制 - 当我们声明一个类型为A
的变量时,编译器分配一个足够大的内存区域来容纳类型为A
的对象,因此没有足够的空间来容纳B
。当这种情况发生时,我们说我们切割了对象,因为我们赋值或复制时只取了对象的一部分。
注意
切割对象不是预期的行为。请注意这种交互,并尽量避免它。
这种行为发生是因为 C++默认使用静态调度进行函数和方法调用:当编译器看到方法调用时,它会检查被调用方法变量的静态类型,并且它将执行A
被调用,并且它只复制A
中B
内部的这部分,忽略剩余的字段。
如前所述,C++支持动态调度。这是通过使用特殊关键字标记方法来完成的:virtual。
如果一个方法被标记为virtual
关键字,当在引用或指针上调用该方法时,编译器将执行动态类型的实现而不是静态类型。
这两个特性实现了多态性 - 我们可以编写一个接受基类引用的函数,调用这个基类的方法,并且派生类的方法将被执行:
void safeTurnOn(Vehicle& vehicle) {
if (vehicle.getFuelInTank() > 0.1 && vehicle.batteryHasEnergy()) {
vehicle.turnOn();
}
}
然后,我们可以用许多不同类型的车辆调用该函数,并且将执行适当的方法:
Car myCar;
Truck truck;
safeTurnOn(myCar);
safeTurnOn(truck);
一个典型的模式是创建一个接口,它只指定了实现某些功能所需的方法。
需要使用这种功能的功能类必须继承接口并实现所有必需的方法。
虚拟方法
我们已经学习了 C++中动态分发的优势以及它是如何使我们能够通过在基类的引用或指针上调用方法来执行派生类的方法。
在本节中,我们将深入了解如何告诉编译器在方法上执行动态分发。指定我们想要为方法使用动态分发的方式是使用virtual
关键字。
在声明方法时,在方法前使用virtual
关键字:
class Vehicle {
public:
virtual void turnOn();
};
我们需要记住,编译器根据调用方法时使用的变量的静态类型来决定如何执行方法分发。
这意味着我们需要将虚拟关键字应用于代码中使用的类型。让我们检查以下练习来探索虚拟关键字。
练习 22:探索虚拟方法
让我们创建一个使用虚拟关键字的继承概念的程序:
-
首先,确保添加所需的头文件和命名空间以编译程序。
-
现在,如所示添加
Vehicle
类:class Vehicle { public: void turnOn() { std::cout << "Vehicle: turn on" << std::endl; } };
-
在
Car
类中,如所示添加virtual
关键字:class Car : public Vehicle { public: virtual void turnOn() { std::cout << "Car: turn on" << std::endl; } }; void myTurnOn(Vehicle& vehicle) { std::cout << "Calling turnOn() on the vehicle reference" << std::endl; vehicle.turnOn(); }
-
现在,在主函数中,调用
Car
类并在myTurnOn()
函数中传递car
对象:int main() { Car car; myTurnOn(car); }
输出将如下所示:
Calling turnOn() on the vehicle reference Vehicle: turn on
这里,调用不会进行动态分发,而是会执行Vehicle::turnOn()
的实现。原因是变量的静态类型是Vehicle
,我们没有将方法标记为virtual
,所以编译器使用静态分发。
我们编写了一个声明方法为虚拟的Car
类的事实并不重要,因为编译器只看到了在myTurnOn()
中使用Vehicle
类。当方法被声明为virtual
时,我们可以在派生类中重写它。
要重写方法,我们需要使用与父类相同的签名声明它:相同的返回类型、名称、参数(包括const
-性、ref
-性),const
限定符和其他属性。
如果签名不匹配,我们将为函数创建一个重载。重载可以从派生类中调用,但它永远不会从基类进行动态分发,例如:
struct Base {
virtual void foo(int) = 0;
};
struct Derived: Base {
/* This is an override: we are redefining a virtual method of the base class, using the same signature. */
void foo(int) { }
/* This is an overload: we are defining a method with the same name of a method of the base class, but the signature is different. The rules regarding virtual do not apply between Base::foo(int) and Derived:foo(float). */
void foo(float) {}
};
当一个类重写基类的虚拟方法时,当在基类上调用该方法时,将执行最派生类的方法。即使该方法是从基类内部调用的,这也是true
,例如:
struct A {
virtual void foo() {
std::cout << "A's foo" << std::endl;
}
};
struct B: A {
virtual void foo() override {
std::cout << "B's foo" << std::endl;
}
};
struct C: B {
virtual void foo() override {
std::cout << "C's foo" << std::endl;
}
};
int main() {
B b;
C c;
A* a = &b;
a->foo(); // B::foo() is executed
a = &c;
a->foo();
/* C::foo() is executed, because it's the most derived Class overriding foo(). */
}
在前面的示例中,我们可以看到一个新关键字:override
关键字。
C++11 引入了这个关键字,使我们能够显式地指定我们正在重写一个方法。这允许编译器在我们使用override
关键字但签名与基类的任何虚拟方法不匹配时给出错误信息。
注意
当你重写一个方法时,始终使用override
关键字。很容易更改基类的签名并忘记更新我们重写方法的所有位置。如果我们不更新它们,它们将变成一个新的重载而不是重写!
在示例中,我们也为每个函数使用了virtual
关键字。这并非必要,因为基类上的虚拟方法会使派生类中具有相同签名的每个方法也变为虚拟方法。
明确使用virtual
关键字是好的,但如果我们已经使用了override
关键字,它可能就多余了——在这些情况下,最好的方式是遵循你正在工作的项目的编码标准。
virtual
关键字可以应用于任何方法。由于构造函数不是方法,因此构造函数不能被标记为虚拟。此外,在构造函数和析构函数内部禁用动态分派。
原因在于,当构建派生类的层次结构时,基类的构造函数会在派生类的构造函数之前执行。这意味着如果我们试图在构建基类时调用派生类的虚拟方法,派生类尚未初始化。
类似地,当调用析构函数时,整个层次结构的析构函数会按相反的顺序执行;首先调用派生类,然后是基类。在析构函数中调用虚拟方法将调用已经析构的派生类的该方法,这是错误的。
虽然构造函数不能被标记为虚拟,但析构函数可以。如果一个类定义了一个虚拟方法,那么它也应该声明一个虚拟析构函数。
当在动态内存或堆上创建类时,声明析构函数为虚拟非常重要。我们将在本章后面看到如何使用类管理动态内存,但到目前为止,重要的是要知道,如果析构函数没有被声明为虚拟,那么一个对象可能只被部分析构。
注意
如果一个方法被标记为虚拟,那么析构函数也应该被标记为虚拟。
活动 24:计算员工工资
我们正在编写一个系统来计算一家公司员工的工资。每位员工都有一个基本工资加上奖金。
对于不是经理的员工,奖金是根据部门的业绩计算的:如果部门达到了目标,他们将获得基本工资的 10%。
该公司还有经理,他们的奖金计算方式不同:如果部门达到了目标,他们将获得基本工资的 20%,加上部门实际成果与预期成果之间差异的 1%。
我们想要创建一个函数,它接受一个员工并计算他们的总工资,将基本工资和奖金相加,无论他们是否是经理。
执行以下步骤:
-
当构造
Department
类时,它接受预期的收入和实际收入,并将它们存储在两个字段中:class Department { public: Department(int expectedEarning, int effectiveEarning) : d_expectedEarning(expectedEarning), d_effectiveEarning(effectiveEarning) {} bool hasReachedTarget() const {return d_effectiveEarning >= d_expectedEarning;} int expectedEarning() const {return d_expectedEarning;} int effectiveEarning() const {return d_effectiveEarning;} private: int d_expectedEarning; int d_effectiveEarning; };
-
定义一个具有两个
virtual
函数getBaseSalary()
和getBonus()
的Employee
类。在其中,实现如果部门目标达成,员工奖金计算的逻辑:class Employee { public: virtual int getBaseSalary() const { return 100; } virtual int getBonus(const Department& dep) const { if (dep.hasReachedTarget()) { return int(0.1 * getBaseSalary()); } return 0; } };
-
创建另一个提供总补偿的函数:
int getTotalComp(const Department& dep) { return getBaseSalary() + getBonus(dep); }
-
创建一个从
Employee
派生的Manager
类。再次,创建相同的虚函数getBaseSalary()
和getBonus()
。在其中,实现如果部门目标达成,Manager
奖金计算的逻辑:class Manager : public Employee { public: virtual int getBaseSalary() const override { return 150; } virtual int getBonus(const Department& dep) const override { if (dep.hasReachedTarget()) { int additionalDeparmentEarnings = dep.effectiveEarning() - dep.expectedEarning(); return int(0.2 * getBaseSalary() + 0.01 * additionalDeparmentEarnings); } return 0; } };
-
实现主程序,并运行程序:
输出将如下所示:
Employee: 110\. Manager: 181
注意
该活动的解决方案可以在第 311 页找到。
C++中的接口
在前面的章节中,我们看到了如何定义一个虚方法,以及编译器在调用它时将如何进行动态分派。
我们在本章中也讨论了接口,但我们从未指定接口是什么。
接口是代码指定调用者需要提供以能够调用某些功能的一种方式。我们在讨论模板及其对使用它们的类型所施加的要求时看到了一个非正式的定义。
接受参数作为接口的函数和方法是一种说法:为了执行我的操作,我需要这些功能;这取决于你提供它们。
要在 C++中指定接口,我们可以使用抽象基类(ABC)。
让我们深入探讨一下这个名字;这个类是:
-
抽象:这意味着它不能被实例化
-
基类:这意味着它被设计为可以从中派生的
任何定义了纯虚方法的类都是抽象
的。纯虚方法是一个以= 0
结尾的虚方法,例如:
class Vehicle {
public:
virtual void turnOn() = 0;
};
纯虚方法是一个不需要定义的方法。在前面的代码中,我们没有任何地方指定Vehicle::turnOn()
的实现。正因为如此,Vehicle
类不能被实例化,因为我们没有为其纯虚方法提供任何可调用的代码。
我们还可以从类中派生并覆盖纯虚方法。如果一个类从抽象基类派生,它可以是以下两种情况之一:
-
如果它声明了额外的纯虚方法,或者如果没有覆盖基类的所有纯虚方法,则另一个抽象基类
-
如果它覆盖了基类的所有纯虚方法,则是一个常规类
让我们继续上一个例子:
class GasolineVehicle: public Vehicle {
public:
virtual void fillTank() = 0;
};
class Car : public GasolineVehicle {
virtual void turnOn() override {}
virtual void fillTank() override {}
};
在这个例子中,Vehicle
是一个抽象基类,GasolineVehicle
也是,因为它没有覆盖Vehicle
的所有纯虚方法。它还定义了一个额外的虚拟方法,Car
类与Vehicle::turnOn()
方法一起覆盖了这个方法。这使得Car
成为唯一的具体类,一个可以实例化的类。
当一个类从多个抽象基类派生时,同样的概念也适用:所有需要覆盖以使类具体并因此可实例化的类的纯虚方法。
虽然抽象基类不能被实例化,但我们可以定义它们的引用和指针。
注意
如果你尝试实例化一个抽象基类,编译器将给出错误,指定哪些方法仍然是纯虚的,从而使该类成为抽象的。
需要特定方法的功能的函数和方法可以接受抽象基类的引用和指针,以及从它们派生的具体类的实例可以被绑定到这样的引用上。
注意
对于接口的消费者来说,定义接口是一种良好的实践。
需要某些功能来执行其操作的函数、方法或类应该定义接口。应该与这些实体一起使用的类应该实现该接口。
由于 C++没有提供专门的关键字来定义接口,而接口仅仅是抽象基类,因此在 C++中设计接口时,有一些最佳实践指南需要遵循:
-
抽象基类不应有任何数据成员或字段。
这种情况的原因在于,一个接口指定了行为,这些行为应该独立于数据表示。由此得出结论,抽象基类应该只包含默认构造函数。
-
抽象基类应该始终定义一个
virtual ~Interface() = default
。我们将在稍后看到为什么析构函数是虚拟的很重要。 -
抽象基类中的所有方法应该是纯虚的。
接口代表需要实现的可预期功能;一个非纯方法是一个实现。实现应该与接口分开。
-
抽象基类中的所有方法都应该声明为
public
。与前一点类似,我们正在定义一组我们期望调用的方法。我们不应该仅将可以调用该方法的功能限制为从接口派生的类。
-
抽象基类中的所有方法都应该针对单一功能。
如果我们的代码需要多个功能,可以创建单独的接口,并且类可以从中派生所有这些接口。这使得我们更容易组合接口。
考虑禁用接口上的复制构造函数和移动构造函数以及赋值运算符。允许接口被复制可能会导致我们之前描述的切片问题:
Car redCar;
Car blueCar;
Vehicle& redVehicle = redCar;
Vehicle& redVehicle = blueCar;
redVehicle = blueVehicle;
// Problem: object slicing!
在最后一个赋值操作中,我们只复制了 Vehicle
部分,因为已经调用了 Vehicle
类的拷贝构造函数。拷贝构造函数不是虚拟的,所以调用的是 Vehicle
中的实现,并且因为它只知道 Vehicle
类的数据成员(应该是没有的),所以 Car
中定义的成员没有被复制!这导致了一些非常难以识别的问题。
一种可能的解决方案是禁用接口的拷贝和移动构造函数以及赋值运算符:Interface(const Interface&) = delete
以及类似的。这的缺点是阻止编译器为派生类创建拷贝构造函数和赋值运算符。
另一种方法是声明拷贝/移动构造函数/赋值运算符为受保护的,这样只有派生类可以调用它们,我们使用它们时不会冒分配接口的风险。
活动 25:检索用户信息
我们正在编写一个应用程序,允许用户购买和出售物品。当用户登录时,我们需要检索一些信息来填充他们的个人资料,例如个人资料的 URL 和全名。
我们的服务在全球许多数据中心运行,以便始终靠近其客户。因此,有时我们想从缓存中检索用户信息,但有时我们想从我们的主数据库中检索。
执行以下操作:
-
让我们编写代码,使其可以独立于数据来源,因此我们创建一个抽象的
UserProfileStorage
类来从UserId
获取CustomerProfile
:struct UserProfile {}; struct UserId {}; class UserProfileStorage { public: virtual UserProfile getUserProfile(const UserId& id) const = 0; virtual ~UserProfileStorage() = default; protected: UserProfileStorage() = default; UserProfileStorage(const UserProfileStorage&) = default; UserProfileStorage& operator=(const UserProfileStorage&) = default; };
-
现在,编写继承自
UserProfileStorage
的UserProfileCache
类:class UserProfileCache : public UserProfileStorage { public: UserProfile getUserProfile(const UserId& id) const override { std::cout << "Getting the user profile from the cache" << std::endl; return UserProfile(); } }; void exampleOfUsage(const UserProfileStorage& storage) { UserId user; std::cout << "About to retrieve the user profile from the storage" << std::endl; UserProfile userProfile = storage.getUserProfile(user); }
-
在
main
函数中,实例化UserProfileCache
类并调用exampleOfUsage
函数,如图所示:int main() { UserProfileCache cache; exampleOfUsage (cache); }
输出如下:
About to retrieve the user profile from the storage
Getting the user profile from the cache
注意
该活动的解决方案可以在第 312 页找到。
动态内存
在本章中,我们遇到了动态内存这个术语。现在让我们更详细地了解什么是动态内存,它解决了什么问题,以及何时使用它。
动态内存是程序可以用来存储对象的内存部分,程序负责维护其正确的生命周期。
它通常也被称为 堆,并且通常是栈的替代品,而栈则由程序自动处理。动态内存通常可以存储比栈大得多的对象,而栈通常有一个限制。
程序可以与操作系统交互以获取动态内存块,这些内存块可以用来存储对象,之后程序必须注意将其归还给操作系统。
从历史上看,开发者会确保调用适当的函数来获取和归还内存,但现代 C++ 自动化了大部分这个过程,因此现在编写正确的程序要容易得多。
在本节中,我们将展示何时以及如何推荐在程序中使用动态内存。
让我们从例子开始:我们想要编写一个创建记录器的函数。当我们执行测试时,我们创建一个名为TestLogger
的特定于测试的记录器,当我们为用户运行程序时,我们想要使用不同的记录器,称为ReleaseLogger
。
我们在这里可以看到接口的一个很好的匹配点——我们可以编写一个定义所有所需日志方法的记录器抽象基类,并且TestLogger
和ReleaseLogger
从它派生。
然后,我们所有的代码在日志时都会使用记录器的引用。
我们如何编写这样的函数?
正如我们在第二章,函数中学到的,我们不能在函数内部创建记录器,然后返回对其的引用,因为它将是一个自动变量,它将在返回后立即被销毁,留下一个悬垂引用。
我们不能在调用函数之前创建记录器,也不能让函数初始化它,因为类型不同,函数知道应该创建哪种类型。
我们需要一些存储,直到我们需要记录器时才有效,以便将记录器放入其中。
只给出一个接口,我们无法知道实现它的类的尺寸,因为可能有多个类实现它,并且它们可能有不同的大小。这阻止了我们为内存中预留一些空间并将指向该空间的指针传递给函数,以便它可以在其中存储记录器。
由于类可以有不同的大小,存储不仅需要比函数保持有效的时间更长,还需要是可变的。这就是动态内存!
在 C++中,有两个关键字用于与动态内存交互——new和free。
new
表达式用于在动态内存中创建新对象——它由new
关键字组成,后跟要创建的对象的类型以及传递给构造函数的参数,并返回指向请求类型的指针:
Car* myCar = new myCar();
new
表达式请求足够大的动态内存来容纳创建的对象,并在该内存中实例化一个对象。然后它返回指向该实例的指针。
程序现在可以使用myCar
指向的对象,直到它决定删除它。要删除指针,我们可以使用delete
表达式:它由delete
关键字后跟一个变量组成,该变量是一个指针:
delete myCar;
delete
关键字调用由其提供的指针指向的对象的析构函数,然后将其最初请求的内存返回给操作系统。
删除指向自动变量的指针会导致以下错误:
Car myCar; // automatic variable
delete &myCar; // This is an error and will likely crash the program
对于每个new
表达式,绝对重要的是,我们只调用一次delete
表达式,并且使用相同的返回指针。
如果我们忘记调用new
函数返回的对象的delete
函数,我们将有两个主要问题:
-
当我们不再需要内存时,不会将其返回给操作系统。这被称为内存泄漏。如果在程序执行期间反复发生,我们的程序将消耗越来越多的内存,直到消耗掉它能获取的所有内存。
-
对象的析构函数不会被调用。
我们在前面章节中看到,在 C++ 中,我们应该利用 RAII(资源获取即初始化)在构造函数中获取所需的资源,并在析构函数中返回它们。
如果我们不调用析构函数,我们可能不会返回一些资源。例如,数据库连接将保持打开状态,即使我们只使用一个连接,我们的数据库也会因为打开的连接太多而挣扎。
如果我们对同一个指针多次调用 delete
,会出现的问题是在第一次调用之后的所有调用都将访问它们不应访问的内存。
结果可能从我们的程序崩溃到删除我们程序当前正在使用的其他资源,导致行为不正确。
现在我们可以看到,如果我们从基类派生,定义基类中的虚拟析构函数为什么非常重要:我们需要确保在调用基对象的 delete
函数时调用运行时类型的析构函数。如果我们对基类指针调用 delete
,而运行时类型是派生类,我们只会调用基类的析构函数,而不会完全析构派生类。
将基类的析构函数设置为虚函数将确保我们将调用派生类的析构函数,因为我们调用它时使用了动态分派。
注意
对于每次调用 new
操作符,必须有且只有一个调用 delete
,使用 new
返回的指针!
这个错误非常常见,并导致许多错误。
就像单个对象一样,我们也可以使用动态内存来创建对象的数组。对于此类用例,我们可以使用 new[]
和 delete[]
表达式:
int n = 15;
Car* cars = new Car[n];
delete[] cars;
new[]
表达式将为 n Car
实例创建足够的空间并将它们初始化,返回指向创建的第一个元素的指针。在这里,我们没有提供构造函数的参数,因此类必须有一个默认构造函数。
使用 new[]
,我们可以指定我们想要初始化多少个元素。这与 std::array
和我们之前看到的内置数组不同,因为 n
可以在运行时决定。
当我们不再需要对象时,我们需要在 new[]
返回的指针上调用 delete[]
。
注意
对于每次调用 new[]
,必须有且只有一个调用 delete[]
,使用 new[]
返回的指针。
new
操作符和 new[]
函数调用,以及 delete
和 delete[]
函数调用,不能混合使用。始终为数组或单个元素配对!
现在我们已经看到了如何使用动态内存,我们可以编写创建我们日志记录器的函数。
函数将在其主体中调用 new
表达式来创建正确类的实例,然后返回基类指针,这样调用它的代码就不需要知道创建的 logger 类型:
Logger* createLogger() {
if (are_tests_running()) {
TestLogger* logger = new TestLogger();
return logger;
} else {
ReleaseLogger logger = new ReleaseLogger("Release logger");
return logger;
}
}
在这个函数中有两点需要注意:
-
即使我们将
new
表达式写了两遍,每次函数调用中new
也只会被调用一次。这表明仅仅确保我们输入
new
和delete
的次数相等是不够的;我们需要理解我们的代码是如何执行的。 -
没有调用
delete
!这意味着调用createLogger
函数的代码需要确保调用delete
。
从这两点来看,我们可以看到手动管理内存为什么容易出错,以及为什么应该尽可能避免这样做。
让我们看看如何正确调用函数的例子:
Logger* logger = createLogger();
myOperation(logger, argument1, argument2);
delete logger;
如果 myOperation
没有在 logger 上调用 delete
,这是动态内存的正确使用。动态内存是一个强大的工具,但手动操作是危险的,容易出错,并且容易出错。
幸运的是,现代 C++ 提供了一些工具,使得所有这些操作都变得容易得多。可以编写整个程序而不直接使用 new
和 delete
。
我们将在下一节中看到这一点。
安全且易于使用的动态内存
在上一节中,我们学习了在处理接口时动态内存如何有用,尤其是在创建派生类的新实例时。
我们还看到了如何处理动态内存可能会很困难——我们需要确保成对地调用 new
和 delete
,否则总是会对我们的程序产生负面影响。幸运的是,自从 C++11 以来,标准库中有工具可以帮助我们克服这些限制——智能指针。
智能指针是类似指针的类型,在此语境中称为原始指针,但具有额外的功能。
我们将探讨标准库中的两个智能指针:std::unique_ptr
和 std::shared_ptr
(适当地读作 delete
)。
它们代表了不同的所有权模型。对象的所有者是确定对象生存期的代码——决定何时创建和销毁对象的代码部分。
通常,所有权与函数或方法的作用域相关联,因为自动变量的生存期由它控制:
void foo() {
int number;
do_action(number);
}
在这种情况下,foo()
函数的作用域拥有 number
对象,并且它将确保在作用域退出时销毁它。
或者,当类被声明为值类型,位于类的数据成员之间时,类可能拥有对象。在这种情况下,对象的生存期将与类的生存期相同:
class A {
int number;
};
number
将在 A
类构造时构造,并在 A
类销毁时销毁。这是自动完成的,因为字段 number
嵌入在类中,类的构造函数和析构函数将自动初始化 number
。
当管理动态内存中的对象时,编译器不再强制执行所有权,但将所有权概念应用于动态内存也是有帮助的——所有者是决定何时删除对象的人。
当对象在函数内部使用new
调用分配时,函数可以是对象的所有者,如下面的示例所示:
void foo() {
int* number = new number();
do_action(number);
delete number;
}
或者,一个类可能通过在构造函数中调用new
并将指针存储在其字段中,并在析构函数中调用delete
来拥有它:
class A {
A() : number(new int(0)) {
}
~A() {
delete number;
}
int* number;
};
但动态对象的所有权也可以传递。
我们之前已经通过createLogger
函数的示例进行了查看。该函数创建了一个Logger
实例,然后将所有权传递给父作用域。现在,父作用域负责确保对象在程序中被访问直到删除。
智能指针允许我们在指针的类型中指定所有权,并确保它得到尊重,这样我们就不必再手动跟踪它了。
注意
总是使用智能指针来表示对象的所有权。
在代码库中,智能指针应该是控制对象生命周期的指针,而原始指针或常规指针仅用于引用对象。
使用 std::unique_ptr 的单个所有者
unique_ptr
是默认使用的指针类型。唯一指针指向一个只有一个所有者的对象;程序中只有一个地方决定何时删除该对象。
例如,之前的日志记录器:程序中只有一个地方决定何时删除对象。由于我们希望日志记录器在程序运行期间始终可用,以便始终能够记录信息,我们将在程序结束时销毁日志记录器。
唯一指针保证了所有权的唯一性:唯一指针不能被复制。这意味着一旦我们为对象创建了一个唯一指针,就只能有一个。
此外,当唯一指针被销毁时,它会删除它拥有的对象。这样,我们就有一个具体的对象,它告诉我们创建的对象的所有权,我们不必手动确保只有一个地方调用delete
来删除对象。
唯一指针是一个模板,它可以接受一个参数:对象的类型。
我们可以将前面的示例重写如下:
std::unique_ptr<Logger> logger = createLogger();
虽然这段代码可以编译,但我们不会遵守之前提到的始终使用智能指针进行所有权的指南:createLogger
返回一个原始指针,但它将所有权传递给父作用域。
我们可以将createLogger
函数的签名更新为返回智能指针:
std::unique_ptr<Logger>createLogger();
现在,签名表达了我们的意图,我们可以更新实现以使用智能指针。
正如我们之前提到的,随着智能指针的使用,代码库不应在任何地方使用new
和delete
。这是可能的,因为自 C++14 以来,标准库提供了一个方便的函数:std::make_unique
。make_unique
是一个模板函数,它接受要创建的对象的类型,并在动态内存中创建它,将参数传递给对象的构造函数,并返回一个指向它的唯一指针:
std::unique_ptr<Logger>createLogger() {
if (are_tests_running()) {
std::unique_ptr<TestLogger> logger = std::make_unique<TestLogger>();
return logger; // logger is implicitly moved
} else {
std::unique_ptr<ReleaseLogger> logger = std::make_unique<ReleaseLogger>("Release logger");
return logger; // logger is implicitly moved
}
}
关于此功能有三个重要点:
-
在函数体中不再有新的表达式;它已经被
make_unique
所取代。make_unique
函数调用简单,因为我们可以提供所有传递给类型构造函数的参数,并自动创建它。 -
我们正在创建一个指向派生类的
unique_ptr
,但我们返回一个指向基类的unique_ptr
。事实上,
unique_ptr
模拟了原始指针将派生类指针转换为基类指针的能力。这使得使用unique_ptr
与使用原始指针一样简单。 -
我们正在使用
unique_ptr
的移动操作。正如我们之前所说的,我们不能复制unique_ptr
,但我们需要从函数返回一个值;否则,在函数返回后,引用将变得无效,就像我们在第二章,函数中看到的那样。虽然
unique_ptr
不能被复制,但它可以被移动。当我们移动unique_ptr
时,我们将指向的对象的所有权转移到值的接收者。在这种情况下,我们返回值,因此我们将所有权转移到函数的调用者。
让我们现在看看我们如何重写之前展示的拥有数字的类:
class A {
A(): number(std::make_unique<int>()) {}
std::unique_ptr<int> number;
};
由于unique_ptr
在销毁时自动删除对象,我们不必为类编写析构函数,这使得我们的代码更加简单。
如果我们需要传递对象的指针,而不转移所有权,我们可以使用原始指针上的get()
方法。记住,原始指针不应用于所有权,接受原始指针的代码永远不应该调用delete
。
多亏了这些特性,unique_ptr
应该是跟踪对象所有权的默认选择。
使用std::shared_ptr
实现共享所有权
shared_ptr
表示一个有多个所有者的对象:几个对象中的一个将删除拥有的对象。
一个例子可以建立一个 TCP 连接,该连接由多个线程建立以发送数据。每个线程使用 TCP 连接发送数据然后终止。
我们希望在最后一个线程执行完毕时删除 TCP 连接,但最后一个终止的线程不一定是同一个;它可能是任何线程。
或者,如果我们正在模拟一个连接节点的图,我们可能希望在从图中删除所有连接后删除一个节点。unique_ptr
不能解决这些情况,因为对象没有单一的所有者。
shared_ptr
可以用于这种情况:shared_ptr
可以被复制多次,并且指针所指向的对象将保持活跃,直到最后一个shared_ptr
被销毁。我们保证只要至少有一个shared_ptr
实例指向它,对象就保持有效。
让我们看看一个利用它的例子:
class Node {
public:
void addConnectedNode(std::shared_ptr<Node> node);
void removeConnectedNode(std::shared_ptr<Node> node);
private:
std::vector<std::shared_ptr<Node>>d_connections;
};
在这里,我们可以看到我们持有许多指向节点的shared_ptr
实例。如果我们有一个指向节点的shared_ptr
实例,我们想要确保节点存在,但当我们移除共享指针时,我们不再关心节点:它可能被删除,或者如果另一个节点与之连接,它可能仍然保持活跃。
与unique_ptr
的对应物类似,当我们想要创建一个新的节点时,我们可以使用std::make_shared
函数,它将构造对象的类型作为模板参数,并将传递给对象构造函数的参数作为参数,并返回指向对象的shared_ptr
。
你可能会注意到,在我们展示的例子中可能存在一个问题:如果节点A
连接到节点B
,而节点B
又连接到节点A
,会发生什么?
两个节点都有一个指向对方的shared_ptr
实例,即使没有其他节点与它们连接,它们也会保持活跃,因为存在指向它们的shared_ptr
实例。这是一个循环依赖的例子。
当使用共享指针时,我们必须注意这些情况。标准库提供了一种不同类型的指针来处理这些情况:std::weak_ptr
(读作弱指针)。
weak_ptr
是一种智能指针,可以与shared_ptr
一起使用,以解决我们程序中可能出现的循环依赖问题。
通常情况下,shared_ptr
足以模拟大多数unique_ptr
无法处理的情况,并且它们共同覆盖了代码库中动态内存的大部分使用。
最后,如果我们想在运行时才知道大小的数组中使用动态内存,我们并不无助。unique_ptr
可以与数组类型一起使用,从 C++17 开始,shared_ptr
也可以与数组类型一起使用:
std::unique_ptr<int[]>ints = std::make_unique<int[]>();
std::shared_ptr<float[]>floats = std::make_shared<float[]>();
活动 26:为 UserProfileStorage 创建工厂
我们的代码需要创建我们在活动 25:检索用户信息期间编写的UserProfileStorage
接口的新实例:
-
编写一个新的
UserProfileStorageFactory
类。现在创建一个新的create
方法,它返回一个UserProfileStorage
: -
在
UserProfileStorageFactory
类中,返回unique_ptr
以便它管理接口的生存期:class UserProfileStorageFactory { public: std::unique_ptr<UserProfileStorage> create() const { // Create the storage and return it } };
-
现在,在
main
函数中,调用UserProfileStorageFactory
类。注意
解决这个活动的方案可以在第 313 页找到。
活动 27:使用数据库连接进行多项操作
在我们的在线商店中,用户支付购买后,我们想要更新他们的订单列表,以便在他们的个人资料中显示。同时,我们还需要安排订单的处理。
要这样做,我们需要更新我们数据库中的记录。
我们不希望等待一个操作完成后再执行另一个操作,因此我们并行处理更新:
-
让我们创建一个
DatabaseConnection
类,它可以并行使用。我们希望尽可能多地重用它,我们知道我们可以使用std::async
来启动一个新的并行任务。 -
假设有两个函数,
updateOrderList(DatabaseConnection&)
和scheduleOrderProcessing(DatabaseConnection&)
,编写两个函数updateWithConnection()
和scheduleWithConnection()
,它们接受指向DatabaseConnection
的共享指针并调用上面定义的相应函数:void updateWithConnection(std::shared_ptr<DatabaseConnection> connection) { updateOrderList(*connection); } void scheduleWithConnection(std::shared_ptr<DatabaseConnection> connection) { scheduleOrderProcessing(*connection); }
-
使用
shared_ptr
并保留shared_ptr
的副本,以确保连接保持有效。 -
现在让我们编写
main
函数,其中我们创建一个指向连接的共享指针,然后我们调用上面定义的两个函数std::async
,如下所示:int main() { std::shared_ptr<DatabaseConnection> connection = std::make_shared<DatabaseConnection>(); std::async(std::launch::async, updateWithConnection, connection); std::async(std::launch::async, scheduleWithConnection, connection); }
输出如下:
Updating order and scheduling order processing in parallel Schedule order processing Updating order list
注意
解决这个活动的方案可以在第 314 页找到。
摘要
在本章中,我们看到了如何在 C++中使用继承来组合类。我们看到了基类是什么,派生类是什么,如何编写从另一个类派生的类,以及如何控制可见性修饰符。我们讨论了如何在派生类中通过调用基类构造函数来初始化基类。
然后,我们解释了多态以及 C++动态绑定派生类指针或引用到基类指针或引用的能力。我们解释了函数调用的分派是什么,默认情况下它是如何静态工作的,以及如何使用虚关键字使其动态化。随后,我们探讨了如何正确编写虚函数以及如何覆盖它们,确保用override
关键字标记这样的覆盖函数。
接下来,我们展示了如何使用抽象基类定义接口以及如何使用纯虚方法。我们还提供了如何正确定义接口的指南。
最后,我们深入探讨了动态内存及其解决的问题,但也看到了如何容易地错误使用它。
我们通过展示现代 C++如何通过提供智能指针来简化动态内存的使用,从而结束了本章的内容,这些智能指针为我们处理复杂的细节:unique_ptr
用于管理单个所有者的对象,shared_ptr
用于多个所有者的对象。
所有这些工具都可以有效地编写出既能够有效进化又能够维护的稳固程序,同时保留 C++所著名的性能。
第八章:附录
关于
本节包含帮助学生执行书中活动的概念。它包括学生为实现活动目标必须执行的详细步骤。
第 1 课:入门
活动一:使用 while
循环在 1 到 100 之间找到 7 的因子
-
在
main
函数之前导入所有必需的头文件:#include <iostream>
-
在
main
函数内部,创建一个类型为unsigned
的变量i
,并将其值初始化为1
:unsigned i = 1;
-
现在,使用
while
循环添加逻辑,其中i
的值应小于100
:while ( i < 100){ }
-
在
while
循环的作用域内,使用以下逻辑的if
语句:if (i%7 == 0) { std::cout << i << std::endl; }
-
将
i
变量的值增加以迭代while
循环以验证条件:i++;
程序的输出如下:
7 14 21 28 ... 98
活动二:定义一个二维数组并初始化其元素
-
在创建 C++ 文件后,在程序开始处包含以下头文件:
#include <iostream>
-
现在,在
main
函数中,创建一个名为foo
的双向数组,类型为整数,具有三行三列,如下所示:int main() { int foo[3][3];
-
现在,我们将使用嵌套
for
循环的概念来迭代foo
数组的每个索引条目:for (int x= 0; x < 3; x++){ for (int y = 0; y < 3; y++){ } }
-
在第二个
for
循环中,添加以下语句:foo[x][y] = x + y;
-
最后,再次迭代数组以打印其值:
for (int x = 0; x < 3; x++){ for (int y = 0; y < 3; y++){ std::cout << “foo[“ << x << “][“ << y << “]: “ << foo[x][y] << std::endl; } }
输出如下:
foo[0][0]: 0 foo[0][1]: 1 foo[0][2]: 2 foo[1][0]: 1 foo[1][1]: 2 foo[1][2]: 3 foo[2][0]: 2 foo[2][1]: 3 foo[2][2]: 4
第 2 课:函数
活动三:计算一个人是否有资格投票或不
-
在程序中包含头文件以打印如下所示的输出:
#include <iostream>
-
现在,创建一个名为
byreference_age_in_5_years
的函数,并使用以下条件编写if
循环以打印消息:void byreference_age_in_5_years(int& age) { if (age >= 18) { std::cout << “Congratulations! You are eligible to vote for your nation.” << std::endl; return;
-
添加
else
块以提供另一个条件,如果用户的年龄小于 18 岁:} else{ int reqAge = 18; int yearsToGo = reqAge-age; std::cout << “No worries, just “<< yearsToGo << “ more years to go.” << std::endl; } }
-
在
main
函数中,创建一个类型为整数的变量,并将其作为引用传递给byreference_age_in_5_years
函数,如下所示:int main() { int age; std::cout << “Please enter your age:”; std::cin >> age; byreference_age_in_5_years(age); }
活动四:在函数中应用通过引用或值传递的理解
-
在添加所有必需的头文件后,创建第一个类型为整数的函数,如下所示:
int sum(int a, int b) { return a + b }
采用值传递,返回值传递,因为类型在内存中较小,没有使用引用的理由。
-
第二个函数应编写如下:
int& getMaxOf(std::array<int, 10>& array1, std::array<int, 10>& array2, int index) { if (array1[index] >= array2[index]) { return array1[index]; } else { return array2[index]; } }
活动五:在命名空间中组织函数
-
包含所需的头文件和命名空间以打印所需的输出:
#include <iostream> using namespace std;
-
现在,创建一个名为
LamborghiniCar
的命名空间,并使用以下output
函数:namespace LamborghiniCar { int output(){ std::cout << “Congratulations! You deserve the Lamborghini.” << std::endl; return NULL; } }
-
创建另一个名为
PorscheCar
的命名空间,并添加一个output
函数,如下所示:namespace PorscheCar { int output(){ std::cout << “Congratulations! You deserve the Porsche.” << std::endl; return NULL; } }
在 main
函数中,创建一个名为 magicNumber
的类型为整数的变量以接受用户的输入:
int main()
{
int magicNumber;
std::cout << “Select a magic number (1 or 2) to win your dream car: “;
std::cin >> magicNumber;
-
添加以下条件
if
…else
-if
…else
语句以完成程序:if (magicNumber == 1){ std::cout << LamborghiniCar::output() << std::endl; } else if(magicNumber == 2){ std::cout << PorscheCar::output() << std::endl; }else{ std::cout << “Please type the correct magic number.” << std::endl; } }
活动六:编写用于 3D 游戏的数学库
-
在程序开始处添加所需的头文件(提供
mathlib.h
文件):#include <mathlib.h> #include <array> #include <iostream>
-
创建一个全局
const
变量,类型为float
,如下所示:const float ENEMY_VIEW_RADIUS_METERS = 5;
-
在
main
函数中,创建两个类型为float
的数组,并分配以下值:int main() { std::array<float, 3> enemy1_location = {2, 2 ,0}; std::array<float, 3> enemy2_location = {2, 4 ,0};
-
现在,创建一个名为
enemy_distance
的float
类型的变量,并使用距离函数在计算后赋值:float enemy_distance = johnny::mathlib::distance(enemy1_location, enemy2_location); float distance_from_center = johnny::mathlib::distance(enemy1_location);
-
使用
mathlib.h
中的circumference
函数,计算并分配敌人可视半径到view_circumference_for_enemy
的float
类型:using johnny::mathlib::circumference; float view_circumference_for_enemy = circumference(ENEMY_VIEW_RADIUS_METERS);
-
创建一个名为
total_distance
的float
类型的变量,并将两个敌人之间的距离差赋值,如下代码所示:float total_distance = johnny::mathlib::total_walking_distance({ enemy1_location, {2, 3, 0}, // y += 1 {2, 3, 3}, // z += 3 {5, 3, 3}, // x += 3 {8, 3, 3}, // x += 3 {8, 3, 2}, // z -= 1 {2, 3, 2}, // x -= 6 {2, 3, 1}, // z -= 1 {2, 3, 0}, // z -= 1 enemy2_location });
-
使用以下打印语句打印输出:
std::cout << “The two enemies are “ << enemy_distance << “m apart and can see for a circumference of “ << view_circumference_for_enemy << “m. To go to from one to the other they need to walk “ << total_distance << “m.”; }
第 3 课:类
活动 7:通过获取器和设置器实现信息隐藏
-
定义一个名为
Coordinates
的类,其成员在private
访问修饰符下:class Coordinates { private: float latitude; float longitude; };
-
添加上述指定的四个操作,并通过在它们的声明前加上
public
访问修饰符使它们公开可访问。设置器(set_latitude
和set_longitude
)应接受一个int
参数并返回void
,而获取器不接收任何参数并返回一个float
:class Coordinates { private: float latitude; float longitude; public: void set_latitude(float value){} void set_longitude(float value){} float get_latitude(){} float get_longitude(){} };
-
现在应该实现四个方法。设置器将给定的值赋给它们应该设置的相应成员;获取器返回存储的值。
class Coordinates { private: float latitude; float longitude; public: void set_latitude(float value){ latitude = value; } void set_longitude(float value){ longitude = value; } float get_latitude(){ return latitude; } float get_longitude(){ return longitude; } };
以下是一个示例:
#include <iostream> int main() { Coordinates washington_dc; std::cout << “Object named washington_dc of type Coordinates created.” << std::endl; washington_dc.set_latitude(38.8951); washington_dc.set_longitude(-77.0364); std::cout << “Object’s latitude and longitude set.” << std::endl; std::cout << “Washington DC has a latitude of “ << washington_dc.get_latitude() << “ and longitude of “ << washington_dc.get_longitude() << std::endl; }
活动 8:在 2D 地图上表示位置
-
第一步是创建一个名为
Coordinates
的类,其中包含坐标作为数据成员。这些是两个浮点值,_latitude
和_longitude
,它们标识地理坐标系统上的坐标。此外,这些数据成员使用private
访问修饰符初始化:class Coordinates { private: float _latitude; float _longitude; };
-
然后,通过一个接受两个参数的
public
构造函数扩展该类,这两个参数用于初始化类的数据成员:class Coordinates { public: Coordinates(float latitude, float longitude) : _latitude(latitude), _longitude(longitude) {} private: int _latitude; int _longitude; };
-
我们还可以添加之前看到的获取器来访问类成员。以下是一个示例:
#include <iostream> int main() { Coordinates washington_dc(38.8951, -77.0364); std::cout << “Object named washington_dc of type Coordinates created.” << std::endl; std::cout << “Washington DC has a latitude of “ << washington_dc.get_latitude() << “ and longitude of “ << washington_dc.get_longitude() << std::endl; }
活动 9:在地图中存储不同位置的多组坐标
-
使用 RAII 编程习惯,编写一个管理数组内存分配和删除的类。该类有一个整数数组作为成员数据,将用于存储值。
构造函数接受数组的大小作为参数。
构造函数还负责分配内存,用于存储坐标。
-
最后,定义一个析构函数,并确保在其实现中释放之前分配的数组。
-
我们可以添加打印语句来可视化正在发生的事情:
class managed_array { public: explicit managed_array(size_t size) { array = new int[size]; std::cout << “Array of size “ << size << “ created.” << std::endl; } ~managed_array() { delete[] array; std::cout << “Array deleted.” << std::endl; } private: int *array; };
-
我们可以使用我们的
managed_array
类如下:int main() { managed_array m(10); }
输出结果如下:
Array of size 10 created. Array deleted.
活动 10:创建苹果实例的 AppleTree 类
-
首先,我们需要创建一个具有
private
构造函数的类。这样,对象就不能被构造,因为构造函数不是公开可访问的:class Apple { private: Apple() {} // do nothing };
-
AppleTree
类被定义,并包含一个名为createFruit
的方法,该方法负责创建一个Apple
并返回它:#include <iostream> class AppleTree { public: Apple createFruit(){ Apple apple; std::cout << “apple created!” << std::endl; return apple; } };
-
如果我们编译此代码,我们将得到一个错误。在此点,
Apple
构造函数是private
的,因此AppleTree
类无法访问它。我们需要将AppleTree
类声明为Apple
的friend
,以便允许AppleTree
访问Apple
的private
方法:class Apple { friend class AppleTree; private: Apple() {} // do nothing }
-
现在可以使用以下代码构造
Apple
对象:int main() { AppleTree tree; Apple apple = tree.createFruit(); }
这将打印以下内容:
apple created!
活动 11:对点对象进行排序
-
我们需要为之前定义的
Point
类添加一个<
操作符的重载。这个重载接受另一个类型为Point
的对象作为参数,并返回一个布尔值,指示该对象是否小于作为参数提供的对象,使用之前定义的比较两个点的方法:class Point { public: bool operator< (const Point &other){ return x < other.x || (x == other.x && y < other.y); } int x; int y; };
-
到目前为止,我们能够比较两个
Point
对象:#include <iostream> int main() { Point p_1, p_2; p_1.x = 1; p_1.y = 2; p_2.x = 2; p_2.y = 1; std::cout << std::boolalpha << (p_1 < p_2) << std::endl; }
-
由于在我们的示例中
p_1.x
被初始化为1
,而p_2.x
被初始化为2
,比较的结果将是true
,这表明在顺序中p_1
比p_2
更早。
活动 12:实现仿函数
-
定义一个由类型为
int
的private
数据成员构成的类,并添加一个构造函数来初始化它:class AddX { public: AddX(int x) : x(x) {} private: int x; };
-
通过调用操作符
operator()
扩展它,它接受一个int
作为参数并返回一个int
。在函数体内的实现应该返回先前定义的x
值与函数参数y
的和:class AddX { public: AddX(int x) : x(x) {} int operator() (int y) { return x + y; } private: int x; };
-
实例化一个刚刚定义的类的对象并调用调用操作符:
int main() { AddX add_five(5); std::cout << add_five(4) << std::endl; }
输出将如下所示:
9
第 04 课:泛型编程和模板
活动 13:从连接中读取对象
-
我们首先包含提供连接和用户账户对象的文件头:
#include <iostream> #include <connection.h> #include <useraccount.h>
-
然后,我们可以开始编写
writeObjectToConnection
函数。声明一个模板,它接受两个typename
参数:一个Object
和一个Connection
。在对象上调用static
方法serialize()
以获取表示对象的std::array
,然后调用连接上的writeNext()
将数据写入它:template<typename Object, typename Connection> void writeObjectToConnection(Connection& con, const Object& obj) { std::array<char, 100> data = Object::serialize(obj); con.writeNext(data); }
-
然后,我们可以编写
readObjectFromConnection
。声明一个模板,它接受与之前相同的两个参数:一个Object
和一个Connection
。在内部,我们调用连接的readNext()
来获取存储在连接中的数据,然后我们调用对象类型的static
方法deserialize()
来获取对象的实例并返回它:template<typename Object, typename Connection> Object readObjectFromConnection(Connection& con) { std::array<char, 100> data = con.readNext(); return Object::deserialize(data); }
-
最后,在
main
函数中,我们可以调用我们创建的序列化对象函数。无论是使用TcpConnection
:std::cout << “serialize first user account” << std::endl; UserAccount firstAccount; TcpConnection tcpConnection; writeObjectToConnection(tcpConnection, firstAccount); UserAccount transmittedFirstAccount = readObjectFromConnection<UserAccount>(tcpConnection);
-
还是使用
UdpConnection
:std::cout << “serialize second user account” << std::endl; UserAccount secondAccount; UdpConnection udpConnection; writeObjectToConnection(udpConnection, secondAccount); UserAccount transmittedSecondAccount = readObjectFromConnection<UserAccount>(udpConnection);
程序的输出如下:
serialize first user account the user account has been serialized the data has been written the data has been read the user account has been deserialized serialize second user account the user account has been serialized the data has been written the data has been read the user account has been deserialized
活动 14:支持多种货币的用户账户
-
我们首先包含定义货币的文件:
#include <currency.h> #include <iostream>
-
我们随后声明了一个模板类
Account
。它应该接受一个模板参数:Currency
。我们将账户的当前余额存储在类型为Currency
的数据成员中。我们还提供了一个方法来提取当前余额的值:template<typename Currency> class Account { public: Account(Currency amount) : balance(amount) {} Currency getBalance() const { return balance; } private: Currency balance; };
-
接下来,我们创建一个名为
addToBalance
的方法。它应该是一个带有单个类型参数的模板,即其他货币。该方法接受一个OtherCurrency
类型的值,并使用to()
函数将其转换为当前账户货币的值,指定要将值转换为哪种货币。然后将其添加到余额中:template<typename OtherCurrency> void addToBalance(OtherCurrency amount) { balance.d_value += to<Currency>(amount).d_value; }
-
最后,我们可以在
main
函数中使用一些数据来尝试调用我们的类:Account<GBP> gbpAccount(GBP(1000)); // Add different currencies std::cout << “Balance: “ << gbpAccount.getBalance().d_value << “ (GBP)” << std::endl; gbpAccount.addToBalance(EUR(100)); std::cout << “+100 (EUR)” << std::endl; std::cout << “Balance: “ << gbpAccount.getBalance().d_value << “ (GBP)” << std::endl;
程序的输出如下:
Balance: 1000 (GBP) +100 (EUR) Balance: 1089 (GBP)
活动 15:为游戏中的数学运算编写一个矩阵类
-
我们首先定义一个
Matrix
类,它接受三个模板参数:一个类型和Matrix
类的两个维度。维度是int
类型。内部,我们创建一个大小为行数乘以列数的std::array
,以便为矩阵的所有元素提供足够的空间。我们添加了一个构造函数来初始化数组为 空,以及一个构造函数来提供值列表:#include <array> template<typename T, int R, int C> class Matrix { // We store row_1, row_2, ..., row_C std::array<T, R*C> data; public: Matrix() : data({}) {} Matrix(std::array<T, R*C> initialValues) : data(initialValues) {} };
-
我们在类中添加了一个
get()
方法来返回对元素T
的引用。该方法需要接受我们想要访问的行和列。 -
我们确保请求的索引在矩阵的范围内,否则我们调用
std::abort()
。在数组中,我们首先存储第一行的所有元素,然后存储第二行的所有元素,依此类推。当我们想要访问第 n 行的元素时,我们需要跳过之前行的所有元素,这些元素是每行的元素数量(即列数)乘以之前的行数,结果如下所示的方法:T& get(int row, int col) { if (row >= R || col >= C) { std::abort(); } return data[row*C + col]; }
-
为了方便起见,我们定义了一个打印类的函数。我们按列分隔所有元素,每列一行打印:
template<typename T, size_t R, size_t C> std::ostream& operator<<(std::ostream& os, Matrix<T, R, C> matrix) { os << ‘\n’; for(int r=0; r < R; r++) { for(int c=0; c < C; c++) { os << matrix.get(r, c) << ‘ ‘; } os << “\n”; } return os; }
-
在
main
函数中,我们现在可以使用我们定义的函数:Matrix<int, 3, 2> matrix({ 1, 2, 3, 4, 5, 6 }); std::cout << “Initial matrix:” << matrix << std::endl; matrix.get(1, 1) = 7; std::cout << “Modified matrix:” << matrix << std::endl;
输出如下:
Initial matrix: 1 2 3 4 5 6 Modified matrix: 1 2 3 7 5 6
解决方案奖励步骤:
-
我们可以添加一个新的方法
multiply
,它接受一个长度为C
的std::array
类型的T
,以const
引用方式,因为我们没有修改它。该函数返回一个类型相同但长度为
R
的数组。 -
我们遵循矩阵-向量乘法的定义来计算结果:
std::array<T, R> multiply(const std::array<T, C>& vector){ std::array<T, R> result = {}; for(size_t r = 0; r < R; r++) { for(size_t c = 0; c < C; c++) { result[r] += get(r, c) * vector[c]; } } return result; }
-
现在,我们可以扩展
main
函数来调用multiply
函数:std::array<int, 2> vector = {8, 9}; std::array<int, 3> result = matrix.multiply(vector); std::cout << “Result of multiplication: [“ << result[0] << “, “ << result[1] << “, “ << result[2] << “]” << std::endl;
输出如下:
Result of multiplication: [26, 87, 94]
活动 16:使矩阵类更容易使用
-
我们首先导入
<functional>
以便访问std::multiplies
:#include <functional>
-
然后,我们将类
template
中的模板参数顺序改变,使得大小参数排在前面。我们还添加了一个新的模板参数Multiply
,这是我们默认用于在vector
元素之间进行乘法运算的类型,并将其实例存储在类中:template<int R, int C, typename T = int, typename Multiply=std::multiplies<T> > class Matrix { std::array<T, R*C> data; Multiply multiplier; public: Matrix() : data({}), multiplier() {} Matrix(std::array<T, R*C> initialValues) : data(initialValues), multiplier() {} };
get()
函数与上一个活动保持相同。 -
现在,我们需要确保
Multiply
方法使用用户提供的Multiply
类型来执行乘法。 -
要做到这一点,我们需要确保调用
multiplier(operand1, operand2)
而不是operand1 * operand2
,这样我们就能使用类内部存储的实例:std::array<T, R> multiply(const std::array<T, C>& vector) { std::array<T, R> result = {}; for(int r = 0; r < R; r++) { for(int c = 0; c < C; c++) { result[r] += multiplier(get(r, c), vector[c]); } } return result; }
-
现在我们可以添加一个示例,说明我们如何使用这个类:
// Create a matrix of int, with the ‘plus’ operation by default Matrix<3, 2, int, std::plus<int>> matrixAdd({ 1, 2, 3, 4, 5, 6 }); std::array<int, 2> vector = {8, 9}; // This will call std::plus when doing the multiplication std::array<int, 3> result = matrixAdd.multiply(vector); std::cout << “Result of multiplication(with +): [“ << result[0] << “, “ << result[1] << “, “ << result[2] << “]” << std::endl;
输出如下:
Result of multiplication(with +): [20, 24, 28]
活动十七:确保在执行账户操作时用户已登录
-
我们首先声明一个模板函数,它接受两个类型参数:一个
Action
类型和一个Parameter
类型。 -
函数应该接受用户标识、操作和参数。参数应该作为转发引用接受。作为第一步,它应该通过调用
isLoggenIn()
函数检查用户是否已登录。如果用户已登录,它应该调用getUserCart()
函数,然后调用操作,传递购物车和转发参数:template<typename Action, typename Parameter> void execute_on_user_cart(UserIdentifier user, Action action, Parameter&& parameter) { if(isLoggedIn(user)) { Cart cart = getUserCart(user); action(cart, std::forward<Parameter>(parameter)); } else { std::cout << “The user is not logged in” << std::endl; } }
-
我们可以通过在
main
函数中调用它来测试execute_on_user_cart
的工作方式:Item toothbrush{1023}; Item toothpaste{1024}; UserIdentifier loggedInUser{0}; std::cout << “Adding items if the user is logged in” << std::endl; execute_on_user_cart(loggedInUser, addItems, std::vector<Item>({toothbrush, toothpaste})); UserIdentifier loggedOutUser{1}; std::cout << “Removing item if the user is logged in” << std::endl; execute_on_user_cart(loggedOutUser, removeItem, toothbrush);
输出如下:
Adding items if the user is logged in Items added Removing item if the user is logged in The user is not logged in
活动十八:使用任意数量的参数安全地执行用户购物车操作
-
我们需要扩展先前的活动以接受任何类型的引用和任意数量的参数,并将其传递给提供的操作。为此,我们需要创建一个
可变参数
模板。 -
声明一个模板函数,它接受一个操作和一个
可变参数
数量的模板参数。函数参数应该是用户操作、要执行的操作以及扩展的模板参数pack
,确保参数作为转发引用被接受。 -
在函数内部,我们执行与之前相同的检查,但现在我们在将参数转发到操作时扩展它们:
template<typename Action, typename... Parameters> void execute_on_user_cart(UserIdentifier user, Action action, Parameters&&... parameters) { if(isLoggedIn(user)) { Cart cart = getUserCart(user); action(cart, std::forward<Parameters>(parameters)...); } else { std::cout << “The user is not logged in” << std::endl; } }
-
让我们在
main
函数中测试这个新函数:Item toothbrush{1023}; Item apples{1024}; UserIdentifier loggedInUser{0}; std::cout << “Replace items if the user is logged in” << std::endl; execute_on_user_cart(loggedInUser, replaceItem, toothbrush, apples); UserIdentifier loggedOutUser{1}; std::cout << “Replace item if the user is logged in” << std::endl; execute_on_user_cart(loggedOutUser, removeItem, toothbrush);
输出如下:
Replace items if the user is logged in Replacing item Item removed Items added Replace item if the user is logged in The user is not logged in
课五:标准库容器和算法
活动十九:存储用户账户
-
首先,我们包含
array
类和输入/输出操作的必要头文件以及所需的命名空间:#include <array>
-
声明了一个包含十个
int
类型元素的数组:array<int,10> balances;
-
初始时,元素的值是未定义的,因为它是一个基本数据类型
int
的数组。数组使用for
循环初始化,其中每个元素使用其索引初始化。使用size()
运算符来评估数组的大小,使用下标运算符[ ]
来访问数组的每个位置:for (int i=0; i < balances.size(); ++i) { balances[i] = 0; }
-
现在,我们想要更新第一个和最后一个用户的值。我们可以使用
front()
和back()
来访问这些用户的账户:balances.front() += 100; balances.back() += 100;
我们希望存储任意数量用户的账户余额。然后我们想要向账户列表中添加 100 个用户,每个用户的余额为 500。
-
我们可以使用
vector
存储任意数量的用户。它在<vector>
头文件中定义:#include <vector>
-
然后,我们声明了一个
int
类型的vector
。可选地,我们可以通过调用reserve(100)
预留足够的内存来存储 100 个用户的账户,以避免内存重新分配:std::vector<int> balances; balances.reserve(100);
-
最后,我们修改
for
循环,在账户向量末尾添加用户的余额:for (int i=0; i<100; ++i) { balances.push_back(500); }
活动 20:根据给定的用户名检索用户的余额
-
包含
map
类的头文件和string
的头文件:#include <map> #include <string>
-
创建一个键为
std::string
,值为int
的映射:std::map<std::string, int> balances;
-
使用
insert
和std::make_pair
将用户的余额插入到map
中。第一个参数是键,第二个参数是值:balances.insert(std::make_pair(“Alice”,50)); balances.insert(std::make_pair(“Bob”, 50)); balances.insert(std::make_pair(“Charlie”, 50));
-
使用
find
函数提供用户名以找到账户在映射中的位置。将其与end()
进行比较以检查是否找到了位置:auto donaldAccountPos = balances.find(“Donald”); bool hasAccount = (donaldAccountPos != balances.end()); std::cout << “Donald has an account: “ << hasAccount << std::endl;
-
现在,寻找
Alice
的账户。我们知道Alice
有账户,所以没有必要检查我们是否找到了有效的位置。我们可以使用->second
打印账户的值:auto alicePosition = balances.find(“Alice”); std::cout << “Alice balance is: “ << alicePosition->second << std::endl;
活动 21:按顺序处理用户注册
-
首先,包括
stack
类的头文件:#include <stack>
-
创建一个提供
store
类型的stack
:std::stack<RegistrationForm> registrationForms;
-
我们在用户注册时开始将表单存储在
stack
中。在storeRegistrationForm
函数的主体中,将元素推入队列:stack.push(form); std::cout << “Pushed form for user “ << form.userName << std::endl;
-
现在,在
endOfDayRegistrationProcessing
内部,我们获取stack
中的所有元素,然后处理它们。使用top()
方法访问stack
中的顶部元素,并使用pop()
移除顶部元素。当我们没有元素时停止获取和移除第一个元素:while(not stack.empty()) { processRegistration(stack.top()); stack.pop(); }
-
最后,我们使用一些测试数据调用我们的函数:
int main(){ std::stack<RegistrationForm> registrationForms; storeRegistrationForm(registrationForms, RegistrationForm{“Alice”}); storeRegistrationForm(registrationForms, RegistrationForm{“Bob”}); storeRegistrationForm(registrationForms, RegistrationForm{“Charlie”}); endOfDayRegistrationProcessing(registrationForms); }
活动 22:机场系统管理
-
我们首先创建
Airplane
类。确保首先包含variant
的头文件:#include <variant>
-
然后,创建一个具有构造函数的类,该构造函数将飞机的当前状态设置为
AtGate
:class Airplane { std::variant<AtGate, Taxi, Flying> state; public: Airplane(int gate) : state(AtGate{gate}) { std::cout << “At gate “ << gate << std::endl; } };
-
现在,实现
startTaxi()
方法。首先,使用std::holds_alternative<>()
检查飞机的当前状态,如果飞机不在正确的状态,则写入错误信息并返回。 -
如果飞机处于正确的状态,则将状态更改为 taxi,通过将其分配给
variant
:void startTaxi(int lane, int numPassengers) { if (not std::holds_alternative<AtGate>(state)) { std::cout << “Not at gate: the plane cannot start taxi to lane “ << lane << std::endl; return; } std::cout << “Taxing to lane “ << lane << std::endl; state = Taxi{lane, numPassengers}; }
-
我们对
takeOff()
方法重复相同的过程:void takeOff(float speed) { if (not std::holds_alternative<Taxi>(state)) { std::cout << “Not at lane: the plane cannot take off with speed “ << speed << std::endl; return; } std::cout << “Taking off at speed “ << speed << std::endl; state = Flying{speed}; }
-
我们现在可以开始查看
currentStatus()
方法。由于我们想要对variant
中的每个状态执行操作,我们可以使用访问者。 -
在
Airplane
类外部,创建一个具有为飞机状态中的每个类型提供operator()
方法的类。在方法内部,打印状态信息。请记住使这些方法为公共:class AirplaneStateVisitor { public: void operator()(const AtGate& atGate) { std::cout << “AtGate: “ << atGate.gate << std::endl; } void operator()(const Taxi& taxi) { std::cout << “Taxi: lane “ << taxi.lane << “ with “ << taxi.numPassengers << “ passengers” << std::endl; } void operator()(const Flying& flying) { std::cout << “Flaying: speed “ << flying.speed << std::endl; } };
-
现在,创建
currentStatus()
方法并使用std::visit
在状态上调用访问者:void currentStatus() { AirplaneStateVisitor visitor; std::visit(visitor, state); }
-
我们现在可以尝试从
main
函数中调用Airplane
的函数:int main() { Airplane airplane(52); airplane.currentStatus(); airplane.startTaxi(12, 250); airplane.currentStatus(); airplane.startTaxi(13, 250); airplane.currentStatus(); airplane.takeOff(800); airplane.currentStatus(); airplane.takeOff(900); }
第 6 课:面向对象编程
活动 23:创建游戏角色
-
创建一个具有
public
方法moveTo
的Character
类,该方法打印Moved to position
:class Character { public: void moveTo(Position newPosition) { position = newPosition; std::cout << “Moved to position “ << newPosition.positionIdentifier << std::endl; } private: Position position; };
-
创建一个名为
Position
的struct
:struct Position { // Fields to describe the position go here std::string positionIdentifier; };
-
创建两个从
Character
类派生的类Hero
和Enemy
:// Hero inherits publicly from Character: it has // all the public member of the Character class. class Hero : public Character { }; // Enemy inherits publicly from Character, like Hero class Enemy : public Character { };
-
创建一个具有打印施展法术的人名的构造函数的
Spell
类:class Spell { public: Spell(std::string name) : d_name(name) {} std::string name() const { return d_name; } private: std::string d_name; };
-
类
Hero
应该有一个公共方法来施展法术。使用Spell
类的值:public: void cast(Spell spell) { // Cast the spell std::cout << “Casting spell “ << spell.name() << std::endl; }
-
类
Enemy
应该有一个公共方法来挥舞剑,打印Swinging sword
:public: void swingSword() { // Swing the sword std::cout << “Swinging sword” << std::endl; }
-
实现调用这些方法的各种类的
main
方法:int main() { Position position{“Enemy castle”}; Hero hero; Enemy enemy; // We call moveTo on Hero, which calls the method inherited // from the Character class hero.moveTo(position); enemy.moveTo(position); // We can still use the Hero and Enemy methods hero.cast(Spell(“fireball”)); enemy.swingSword(); }
活动 24:计算员工工资
-
我们可以创建一个具有两个虚拟方法
getBaseSalary
和getBonus
的Employee
类,因为我们希望根据员工类型更改这些方法:class Employee { public: virtual int getBaseSalary() const { return 100; } virtual int getBonus(const Deparment& dep) const { if (dep.hasReachedTarget()) { } return 0; }
-
我们还定义了一个方法
getTotalComp
,它不需要是虚拟的,但会调用两个虚拟方法:int getTotalComp(const Deparment& dep) { } };
-
然后,从它派生出
Manager
类,重写计算奖金的方法。我们可能还希望重写getBaseSalary
,如果我们想给经理提供不同的基本工资:class Manager : public Employee { public: virtual int getBaseSalary() const override { return 150; } virtual int getBonus(const Deparment& dep) const override { if (dep.hasReachedTarget()) { int additionalDeparmentEarnings = dep.effectiveEarning() - dep.espectedEarning(); return 0.2 * getBaseSalary() + 0.01 * additionalDeparmentEarnings; } return 0; } };
-
创建一个
Department
类,如下所示:class Department { public: bool hasReachedTarget() const {return true;} int espectedEarning() const {return 1000;} int effectiveEarning() const {return 1100;} };
-
现在,在
main
函数中,按照如下所示调用Department
、Employee
和Manager
类:int main() { Department dep; Employee employee; Manager manager; std::cout << “Employee: “ << employee.getTotalComp(dep) << “. Manager: “ << manager.getTotalComp(dep) << std::endl; }
活动 25:检索用户信息
-
我们必须编写可以独立于数据来源的代码。因此,我们创建了一个接口
UserProfileStorage
,用于从UserId
检索CustomerProfile
:struct UserProfile {}; struct UserId {}; class UserProfileStorage { public: virtual UserProfile getUserProfile(const UserId& id) const = 0; virtual ~UserProfileStorage() = default; protected: UserProfileStorage() = default; UserProfileStorage(const UserProfileStorage&) = default; UserProfileStorage& operator=(const UserProfileStorage&) = default; };
-
现在,编写继承自
UserProfileStorage
的UserProfileCache
类:class UserProfileCache : public UserProfileStorage { public: UserProfile getUserProfile(const UserId& id) const override { std::cout << “Getting the user profile from the cache” << std::endl; return UserProfile(); } }; void exampleOfUsage(const UserProfileStorage& storage) { UserId user; std::cout << “About to retrieve the user profile from the storage” <<std::endl; UserProfile userProfile = storage.getUserProfile(user); }
-
在
main
函数中,按照如下所示调用UserProfileCache
类和exampleOfUsage
函数:int main() { UserProfileCache cache; exampleOfUsage (cache); }
活动 26:创建 UserProfileStorage 工厂
-
编写以下需要
UserProfileStorage
类的代码,如下所示。为了实现这一点,我们提供了一个工厂类,它有一个create
方法,提供UserProfileStorage
的实例。编写这个类时,确保用户不需要手动管理接口的内存:#include <iostream> #include <memory> #include <userprofile_activity18.h> class UserProfileStorageFactory { public: std::unique_ptr<UserProfileStorage> create() const { return std::make_unique<UserProfileCache>(); } };
-
我们希望
UserProfileStorageFactory
类返回一个unique_ptr
,以便它管理接口的生存期:void getUserProfile(const UserProfileStorageFactory& storageFactory) { std::unique_ptr<UserProfileStorage> storage = storageFactory.create(); UserId user; storage->getUserProfile(user); // The storage is automatically destroyed }
-
现在,在
main
函数中,按照如下所示调用UserProfileStorageFactory
类:int main() { UserProfileStorageFactory factory; getUserProfile(factory);
活动 27:使用数据库连接进行多项操作
-
首先,创建一个可以在并行中使用
DatabaseConnection
类。我们希望尽可能多地重用它,我们知道我们可以使用std::async
来启动一个新的并行任务:#include <future> struct DatabaseConnection {};
-
假设有两个函数
updateOrderList(DatabaseConnection&)
和scheduleOrderProcessing(DatabaseConnection&)
,编写一个函数来创建DatabaseConnection
并将其传递给两个并行任务。(注意,我们不知道哪个任务先完成):void updateOrderList(DatabaseConnection&) {} void scheduleOrderProcessing(DatabaseConnection&) {}
-
你必须理解何时以及如何创建
shared_ptr
。你也可以使用以下代码来正确编写shared_ptr
。/* We need to get a copy of the shared_ptr so it stays alive until this function finishes */ void updateWithConnection(std::shared_ptr<DatabaseConnection> connection) { updateOrderList(*connection); }
有多个用户使用这个连接,我们不知道哪个是所有者,因为只要有人使用它,连接就需要保持活跃。
-
为了模拟这种情况,我们使用
shared_ptr
。记住,为了使连接保持有效,我们需要一个shared_ptr
的副本:/* We need to get a copy of the shared_ptr so it stays alive until this function finishes. */ void scheduleWithConnection(std::shared_ptr<DatabaseConnection> connection) { scheduleOrderProcessing(*connection); }
-
创建
main
函数如下:int main() { std::shared_ptr<DatabaseConnection> connection = std::make_shared<DatabaseConnection>(); std::async(std::launch::async, updateWithConnection, connection); std::async(std::launch::async, scheduleWithConnection, connection); }