探索-C--20-全-
探索 C++20(全)
一、打磨你的工具
在开始探索 C++ 环境之前,您必须收集一些基本的工具:文本编辑器、C++ 编译器、链接器和调试器。您可以单独或捆绑购买这些工具,可能作为集成开发环境(IDE)的一揽子交易。无论您的平台、操作系统和预算如何,都有很多选择。
如果你正在上课,老师会提供工具或指示使用哪些工具。如果你在一个已经使用 C++ 的组织中工作,你可能想使用它的工具,这样你就可以熟悉它们和它们的正确用法。如果你必须获得自己的工具,检查这本书的网站, https://cpphelp.com/exploring/
。工具版本和质量变化太快,无法以印刷形式提供详细信息,因此您可以在网站上找到最新的建议。以下部分给出了一些一般性建议。
C++ 版本
这本书涵盖了 C++ 20,这是标准化委员会在 2020 年批准的 C++ 标准的重大更新。C++ 20 引入了几个主要特性,所有编译器实现这些特性都需要时间。本书中的大多数代码清单只能用最新的 C++ 20 编译器编译,所以请确保您使用的是所有工具的最新版本。即使这样,您也可能无法编译所有的示例。事实上,你可能一个也编译不出来。
其中一个主要的特性,模块,影响着每一个程序。如果您的环境不完全支持这个特性,您可能无法编译任何代码清单。为了帮助你,这本书的网站提供了所有代码示例的转换副本,以避免使用模块,但所有其他 C++ 20 特性保持不变。
雷的建议
C++ 是世界上使用最广泛的编程语言之一(取决于你如何衡量“广泛使用”)。因此,C++ 工具大量存在于许多硬件和软件环境中,价格也各不相同。
您可以选择命令行工具,这在 UNIX 和类 UNIX 环境中特别流行,或者您可以选择 IDE,它将所有工具捆绑到一个单一的图形用户界面(GUI)中。选择你觉得最舒服的风格。你的程序不会关心你用什么工具来编辑、编译和链接它们。
Clang 和 LLVM
Clang 是一个 C++ 编译器(以及其他语言),它在幕后使用 LLVM 来编译和优化程序。(不,LLVM 不代表任何东西。)macOS 使用 clang 作为默认编译器,很多 Linux 开发者也喜欢使用 clang。你甚至可以为微软 Windows 下载 clang 和 LLVM。
一些 Linux 发行版已经包含了 clang 和 LLVM。对于其他发行版,通常可以从发行版的中央存储库或者直接从 LLVM 网站下载。链接见 cpphelp.com/exploring
。
GNU 编译器集合
最广泛使用的 C++ 编译器是 GNU 编译器集合(GCC)的一部分。GNU C++ 编译器通常被称为 g++。它通常是 Linux 发行版的默认 C++ 编译器,也可用于 macOS 和 Microsoft Windows。
微软视窗软件
大多数使用 Microsoft Windows 的 C++ 开发人员使用微软自己的编译器,这些编译器包含在他们的 Visual Studio 产品中,可以免费下载。Visual Studio 在一个保护伞下积累了许多工具,可能相当复杂,所以一定要下载 C++ 编译器,并且只在标准 C++ 模式下使用,而不是 C++/CLI,这是一种不同的语言。
在 Microsoft Windows 上使用 clang 时,还需要 GnuWin32 用于一些相关的实用程序。Cygwin 和 MinGW 项目包括 GCC。
其他工具
微软提供了 Visual Studio 代码,这是一个运行在所有流行平台上的 IDE。它可以与您平台上的首选编译器集成。其他流行的 ide 包括 Eclipse 和 NetBeans。
C++ 需要编译器和标准库。大多数 C++ 产品都包括这两种库,但有时,利基编译器希望您使用不同产品的库。例如,你可以为他们的硬件下载英特尔的编译器。编译器的优化器是一流的,但是您还需要一个库,比如 g++ 附带的 libstdc++。
作者的网站(cpphelp.com/exploring
)有安装和使用这些工具的有用提示链接。
书中的大多数代码清单和代码片段都有相关的测试。您需要 Python 3 来运行测试。代码中包含了CMakeLists.txt
文件,因此您可以使用 cmake 构建和测试每个代码样本,cmake 是一个用于构建软件的跨平台工具。
阅读文档
现在您已经有了工具,请花些时间阅读产品文档——尤其是入门部分。真的,我是认真的。查找教程和其他快速介绍,帮助您快速掌握工具。如果您正在使用 IDE,您尤其需要知道如何创建简单的命令行项目。
ide 通常要求您在实际编写 C++ 程序之前,创建一个项目、工作区或其他一些信封或包装。你一定知道怎么做,我帮不了你,因为每个 IDE 都不一样。如果您可以选择项目模板,请选择“控制台”、“命令行”、“终端”、“C++ 工具”或一些具有类似名称的项目。
阅读编译器和其他工具的文档花了你多长时间?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
时间是太多了,还是太少了,还是刚刚好?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
C++ 语言遵循国际标准。每个编译器(或多或少)都遵循那个标准,但也加入了一些非标准的额外内容。对于某些项目来说,这些额外的东西可能是有用的,甚至是必要的,但是对于本书来说,你必须确保你只使用标准的 C++。大多数编译器可以关闭它们的扩展。即使您以前没有阅读过该文档,现在也要阅读,以了解您需要哪些选项来使您能够编译标准 C++ 和只编译标准 C++。
记下选项,以备将来参考。
您可能错过了一些选项;它们可能很模糊。为了帮助您,表 1-1 列出了 Microsoft Visual C++、g++ 和 clang 所需的命令行编译器选项。这本书的网站为其他一些流行的编译器提供了建议。如果您使用的是 IDE,请查看项目选项或属性以找到等效项。
表 1-1。
标准 C++ 的编译器选项
|编译程序
|
选择
|
| --- | --- |
| Visual Studio 命令行 | /EHsc /Za
|
| 成开发环境 | 启用 C++ 异常,禁用语言扩展 |
| g++ | -pedantic -std=c++20
|
| clang/llvm | -pedantic -std=c++20
|
你的第一个程序
现在你有了工具,是时候开始了。启动您最喜欢的文本编辑器或 C++ IDE,开始您的第一个项目或创建一个新文件。将这个文件命名为list0101.cpp
,是列表 1-1 的简称。几种不同的文件扩展名在 C++ 程序中很流行。我喜欢用.cpp
,这里的 p 表示“加”。其他常见的扩展名有.cxx
和.cc
。有些编译器会将.C
(大写 C )识别为 C++ 文件扩展名,但我不建议使用它,因为它太容易与 C 程序的默认扩展名.c
(小写 c )混淆。许多桌面环境不区分大小写文件名,这进一步加剧了问题。挑选你最喜欢的,坚持下去。键入清单 1-1 中包含的文本。(除了一个例外,你可以从本书的网站下载所有代码清单。清单 1-1 是个例外。我希望你习惯于在你的文本编辑器中输入 C++ 代码。)
/// This program examines features of the C++ library
/// to deduce and print the C++ version.
#include <algorithm>
#include <iomanip>
#include <iostream>
#include <iterator>
#include <ostream>
#include <string>
#include <vector>
template<std::size_t N>
struct array
{
char array[N];
enum { size = N };
};
template<int I>
struct value_of
{};
template<>
struct value_of<1>
{
enum { value = true };
};
template<>
struct value_of<2>
{
enum { value = false };
};
void* erase(...);
struct is_cpp20
{
static array<1> deduce_type(std::vector<int>::size_type);
static array<2> deduce_type(...);
static std::vector<int> v;
static int i;
enum { value = value_of<sizeof(deduce_type(erase(v, i)))>::value };
};
struct is_cpp17
{
static array<1> deduce_type(char*);
static array<2> deduce_type(const char*);
static std::string s;
enum { value = value_of<sizeof(deduce_type(s.data()))>::value };
};
int cbegin(...);
struct is_cpp14
{
static array<1> deduce_type(std::string::const_iterator);
static array<2> deduce_type(int);
enum { value = value_of<sizeof(deduce_type(cbegin(std::string())))>::value };
};
int move(...);
struct is_cpp11
{
template<class T>
static array<1> deduce_type(T);
static array<2> deduce_type(int);
static std::string s;
enum { value = value_of<sizeof(deduce_type(move(s)))>::value };
};
enum { cpp_year =
is_cpp20::value ? 2020 :
is_cpp17::value ? 2017 :
is_cpp14::value ? 2014 :
is_cpp11::value ? 2011 :
2003
};
int main()
{
std::cout << "C++ " << std::setfill('0') << std::setw(2) << cpp_year%100 << '\n';
std::cout << "C++ " << std::setw(2) << (__cplusplus / 100) % 100 << '\n';
}
Listing 1-1.Your First C++ Program
毫无疑问,这些代码的一部分或全部对你来说是胡言乱语。没关系。这个练习的目的不是理解 C++,而是确保你能正确地使用你的工具。我可以从一个简单的“Hello,world”类型的程序开始,但这只是语言和库的一小部分。这个程序寻找在不同版本的 C++ 标准中引入的标准库的特性,以确定你使用的是哪个版本。
现在回去仔细检查你的源代码。确保你输入的一切都是正确的。
你真的仔细检查过这个程序了吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
你有没有发现任何需要改正的错别字?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
犯错是人之常情,排印错误并不可耻。我们都会犯错。回去重新检查你的程序。
现在编译你的程序。如果您使用的是 IDE,请找到“编译”或“构建”按钮或菜单项。如果你使用命令行工具,一定要链接程序。出于历史(或歇斯底里)的原因,UNIX 工具如 g++ 通常会产生一个名为a.out
的可执行程序。您应该将其重命名为更有用的名称,或者使用-o
选项来命名输出文件。表 1-2 显示了用于 Visual C++、g++ 和 clang 的示例命令行。
表 1-2。
编译器 list0101.cpp 的示例命令行
|编译程序
|
命令行
|
| --- | --- |
| Visual C++ | cl /EHsc /Za list0101.cpp
|
| g++ | g++ -o list0101 -pedantic -std=c++20 list0101.cpp
|
| Clang
| clang++ -o list0101 -pedantic -std=c++20 list0101.cpp
|
如果你从编译器那里收到任何错误,那就意味着你在输入源代码时犯了一个错误;编译器、链接器或 C++ 库安装不正确。或者编译器、链接器或库不符合 C++ 标准,因此不适合在本书中使用。再三检查你输入的文本是否正确。如果您确信错误出在工具上,而不是您,请检查发布日期。如果工具早于 2020 年,它们早于标准。因此,根据定义,它们不能符合标准。编译器供应商努力确保他们的工具符合最新标准,但这需要时间。在全球疫情中,我们可能要等很长时间才能看到真正实现足够有用的 C++ 20 标准的编译器。
如果其他方法都失败了,尝试不同的工具。下载 GCC 或 Visual Studio 的当前版本。你可能不得不为这本书使用这些工具,即使你必须为你的工作使用一些粗糙、生锈的旧工具。
成功的编译是一回事,成功的执行是另一回事。如何调用程序取决于操作系统。在 GUI 环境中,您需要一个控制台或终端窗口来输入命令行。您可能需要键入可执行文件的完整路径或仅键入程序名,这同样取决于您的操作系统。当您运行程序时,它从标准输入流中读取文本,这意味着无论您键入什么,程序都会读取。然后,你必须通知程序你已经完成了,通过按下魔法键来表示文件结束。在大多数类似 UNIX 的操作系统上,按 Ctrl+D。在 Windows 上,按 Ctrl+Z。
从 IDE 中运行控制台应用程序有时很棘手。如果您不小心,IDE 可能会在您有机会看到程序的任何输出之前就关闭程序的窗口。您必须确保窗口保持可见。有些 ide(如 Visual Studio 和 KDevelop)会自动为您完成这项工作,要求您在它关闭窗口之前按下最后一个回车键。
如果 IDE 没有自动保持窗口打开,并且您找不到任何选项或设置来保持窗口打开,您可以通过在程序的右大括号或调试器允许您设置断点的最近语句上设置断点来强制解决该问题。
你如何测试 list0101 以确保它正确运行?
好吧,动手吧。程序运行是否正确?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
该程序打印两行。第一个是程序从你的环境中推断出来的东西。第二个是编译器和库声称它们实现了什么。我希望他们是一样的。
好了,这很容易,不是吗?阅读编译器的文档,了解如何设置所需的 C++ 版本,并重新编译和运行程序。确保结果与您指定的版本相匹配。
在你完成这次探索之前,我还有一个练习。这一次,源文件更加复杂。这是一个专业特技程序员写的。不要试图阅读此程序,即使有成人监督。不要试图理解这个程序。最重要的是,不要模仿这个程序中使用的编程风格。这个练习不是给你的,是给你的工具的。它的目的是看你的编译器是否能正确编译这个程序,以及你的库实现是否有标准库的必要部分。对于编译器来说,这不是一个严峻的考验,但它确实触及了一些高级 C++ 特性。
所以不要费心去阅读代码。只需从该书的网站下载文件list0102.cpp
并尝试用你的工具编译和链接它。(我把程序全文收录进来,只为缺乏便捷上网的读者。)如果你的编译器不能正确编译和运行清单 1-2 ,你必须替换它(你的编译器,而不是程序)。在早期的课程中,你可能勉强过关,但是到本书结束时,你将会编写一些相当复杂的程序,你需要一个能够胜任这项任务的编译器。
/// Sort the standard input alphabetically.
/// Read lines of text, sort them, and print the results to the standard output.
/// If the command line names a file, read from that file. Otherwise, read from
/// the standard input. The entire input is stored in memory, so don’t try
/// this with input files that exceed available RAM.
///
/// Comparison uses a locale named on the command line, or the default, unnamed
/// locale if no locale is named on the command line.
#include <cerrno>
#include <cstdlib>
import <algorithm>;
import <fstream>;
import <initializer_list>;
import <iostream>;
import <iterator>;
import <locale>;
import <string>;
import <system_error>;
import <vector>;
template<class C>
struct text : std::basic_string<C>
{
using super = std::basic_string<C>;
constexpr text() noexcept : super{} {}
text(text&&) = default;
text(text const&) = default;
text& operator=(text const&) = default;
text& operator=(text&&) = default;
constexpr explicit operator bool() const noexcept {
return not this->empty();
}
};
/// Read lines of text from @p in to @p iter. Lines are appended to @p iter.
/// @param in the input stream
/// @param iter an output iterator
template<class Ch>
auto read(std::basic_istream<Ch>& in) -> std::vector<text<Ch>>
{
std::vector<text<Ch>> result;
text<Ch> line;
while (std::getline(in, line))
result.emplace_back(std::move(line));
return result;
}
/// Main program.
int main(int argc, char* argv[])
try
{
// Throw an exception if an unrecoverable input error occurs, e.g.,
// disk failure.
std::cin.exceptions(std::ios_base::badbit);
// Part 1\. Read the entire input into text. If the command line names a file,
// read that file. Otherwise, read the standard input.
std::vector<text<char>> text; ///< Store the lines of text here
if (argc < 2)
text = read(std::cin);
else
{
std::ifstream in{argv[1]};
if (not in)
{
std::cout << argv[1] << ": " << std::system_category().message(errno) << '\n';
return EXIT_FAILURE;
}
text = read(in);
}
// Part 2\. Sort the text. The second command line argument, if present,
// names a locale, to control the sort order. Without a command line
// argument, use the default locale (which is obtained from the OS).
std::locale const& loc{ std::locale(argc >= 3 ? argv[2] : "") };
std::collate<char> const& collate{ std::use_facet<std::collate<char>>(loc) };
std::ranges::sort(text,
&collate
{
return collate.compare(to_address(cbegin(a)), to_address(cend(a)),
to_address(cbegin(b)), to_address(cend(b))) < 0;
}
);
// Part 3\. Print the sorted text.
for (auto const& line : text)
std::cout << line << '\n';
}
catch (std::exception& ex)
{
std::cerr << "Caught exception: " << ex.what() << '\n';
std::cerr << "Terminating program.\n";
std::exit(EXIT_FAILURE);
}
catch (...)
{
std::cerr << "Caught unknown exception type.\nTerminating program.\n";
std::exit(EXIT_FAILURE);
}
Listing 1-2.Testing Your Compiler
我抓到你偷看。你不顾我的警告,试图读取源代码,是吗?请记住,我故意用复杂的方式编写这个程序来测试您的工具。当你读完这本书的时候,你将能够阅读和理解这个程序。更重要的是,你将能够写得更简单、更干净。然而,在你能跑之前,你必须学会走。一旦你习惯了使用工具,就该开始学习 C++ 了。接下来的探索从阅读课开始。
二、读取 C++ 代码
我怀疑你已经有了一些 C++ 的知识。也许你已经知道 C,Java,Perl,或者其他类似 C 的语言。也许你知道这么多的语言,你可以很容易地确定共同的元素。让我们来验证我的假设。花几分钟阅读清单 2-1 ,然后回答后面的问题。
1 /// Read the program and determine what the program does.
2
3 import <iostream>;
4 import <limits>;
5
6 int main()
7 {
8 int min{std::numeric_limits<int>::max()};
9 int max{std::numeric_limits<int>::min()};
10 bool any{false};
11 int x;
12 while (std::cin >> x)
13 {
14 any = true;
15 if (x < min)
16 min = x;
17 if (x > max)
18 max = x;
19 }
20
21 if (any)
22 std::cout << "min = " << min << "\nmax = " << max << '\n';
23 }
Listing 2-1.Reading Test
清单 2-1 是做什么的?
清单 2-1 从标准输入中读取整数,并跟踪输入的最大值和最小值。输入完毕后,它打印这些值。如果输入不包含数字,程序将不打印任何内容。
让我们仔细看看程序的各个部分。
评论
第 1 行以三个连续的斜杠开始注释。注释在行尾结束。实际上,你只需要两个斜杠来表示一个注释的开始(//
),但是正如你将在本书后面学到的,额外的斜杠有特殊的含义。
请注意,斜线之间不能有空格。这通常适用于 C++ 中的所有多字符符号。这是一条重要的规则,也是你必须尽早记住的规则。“符号中没有空格”规则的一个推论是,当 C++ 看到相邻字符时,它通常会构造尽可能长的符号,即使您可以看到这样做会产生无意义的结果。
用 C++ 编写注释的另一种方法是以/*
开始注释,以*/
结束注释。这种风格和清单 2-1 中展示的风格的区别在于,使用这种方法,你的注释可以跨越多行。你可能会注意到,本书中的一些程序使用/**
来开始注释。与清单 2-1 中的第三个斜线非常相似,第二个星号(*
)很神奇,但此时并不重要。一个注释不能嵌套在同一风格的注释中,但是你可以将一种风格的注释嵌套在另一种风格的注释中,如清单 2-2 所示。
/* Start of a comment /* start of comment characters are not special in a comment
// still in a comment
Still in a comment
*/
no_longer_in_a_comment();
// Start of a comment /* start of comment characters are not special in a comment
no_longer_in_a_comment();
Listing 2-2.Demonstrating
Comment Styles and Nesting
C++ 社区广泛使用这两种风格。习惯于看到和使用这两种风格。
修改清单 2-1 ,将///
注释改为使用/**
...*/
风格,然后尝试重新编译程序。会发生什么?
如果您做了正确的更改,程序应该仍然可以正常编译和运行。编译器完全删除了注释,所以最终的程序应该没有什么不同。(一个例外是,一些二进制格式包含时间戳,这必然会因编译运行的不同而不同。)
模块
清单 2-1 的第 3 行和第 4 行从部分标准库中导入声明和定义。像 C 和许多其他语言一样,C++ 区分了核心语言和标准库。两者都是标准语言的一部分,没有这两部分,工具套件是不完整的。区别在于核心语言是自成体系的。例如,某些类型是内置的,编译器天生就知道它们。其他类型是根据内置类型定义的,因此它们是在标准库中声明的,并且您必须指示编译器您想要使用它们。这就是第 3 行和第 4 行的内容。
IMPORTING VS. INCLUDING
当我写这篇文章时,没有编译器(甚至是最新的预发行版)能够编译清单 2-1 ,因为它的import
声明。理解import
是做什么的以及它是如何工作的非常复杂,以至于在探索 2 之前我不会涉及它。但是它在这里,干扰探索。
任何时候你看到一个import
声明,你都可以把它改成一个#include
指令。只需将import
替换为#include
,并删除行尾的分号。该书网站上的代码清单( https://cpphelp.com/exploring/
)提供了两种风格的文件。下载适用于您的开发环境的文件,而不用担心import
或#include
的实际含义。
实现关键字import
只是一个更大任务的一部分,所以编译器和库需要一些时间才能跟上。在那之前,我们有一个变通办法。
特别是,第 3 行通知编译器标准 I/O 流的名称(std::cin
表示标准输入,std::cout
表示标准输出)、输入操作符(>>
)和输出操作符(<<
)。第四行带来了std::numeric_limits
这个名字。注意,标准库中的名字一般以std::
(“标准”的简称)开头。
按照 C++ 的说法,import
关键字也是一个动词,比如“第 3 行导入模块iostream
”,“第 4 行导入limits
模块”,等等。一个模块包含一系列声明和定义。(声明是一种定义。定义告诉编译器更多的是名字而不是声明。先不要担心区别,但是请注意我何时使用声明以及何时使用定义。)编译器需要这些声明和定义,所以它知道如何处理像std::cin
这样的名字。在 C++ 编译器和标准库的文档中,有关于标准模块的信息。如果您很好奇,您可以访问包含标准模块源代码的文件夹或目录,看看您能在那里找到什么,但是如果您不能理解它们,请不要失望。C++ 标准库充分利用了 C++ 语言的全部功能。很可能在你读完这本书的大部分内容之前,你无法理解库的大部分内容。
另一个重要的 C++ 规则:编译器必须知道每个名字的意思。人类通常可以从上下文中推断出意思或者至少是一个词类。例如,如果我说,“我把我的饮料弄得满衬衫都是”,你可能不知道furled到底是什么意思,但你可以推断出它是动词的过去式,它可能意味着一些不受欢迎的和有点混乱的事情。
C++ 编译器比你笨多了。当编译器读取一个符号或标识符时,它必须确切地知道这个符号或标识符是什么意思,以及它是“语音”的哪一部分。符号是标点符号(比如语句结尾的分号)还是运算符(比如加法的加号)?标识符是一种类型吗?一个功能?一个变量?编译器还必须知道你可以用那个符号或名字做的一切,这样它才能正确地编译代码。它能知道的唯一方法就是你告诉它,而你告诉它的方法就是写一个声明或者从一个模块导入一个声明。这就是import
声明的意义所在。
在本书的后面,您甚至将学习编写自己的模块。
修改第 4 行,将limits
拼错为stimil
。试着编译程序。会发生什么?
编译器找不到任何名为stimil
的模块,所以它发出一条消息。然后它可能试图编译程序,但是它不知道std::numeric_limits
是什么,所以它发出一个或多个消息。一些编译器级联消息,这意味着每次使用std::numeric_limits
都会产生额外的消息。实际误差消失在噪声中。关注编译器发出的前一条或几条消息。修复它们,然后再试一次。随着你获得 C++ 的经验,你会知道哪些消息仅仅是噪音,哪些是重要的。不幸的是,大多数编译器不会告诉你,例如,你不能使用std::numeric_limits
,直到你包含了<limits>
模块。相反,您需要一个好的 C++ 语言参考,这样您就可以自己查找正确的头文件。首先要检查的是编译器和库附带的文档。作者比编译器作者更慢地赶上了 C++ 20 的标准,所以要经常查看网站和书店的最新参考资料。
大多数程序员不怎么用<limits>
;清单 2-1 包含它只是为了获得std::numeric_limits
的定义。另一方面,本书中几乎每个程序都使用<iostream>
,因为它声明了 I/O 流对象的名称和类型,std::cin
和std::cout
。还有其他的 I/O 模块,但是对于基本的控制台交互,你只需要<iostream>
。在接下来的探索中,你会遇到更多的模块。
主程序
每个 C++ 程序都必须有int main()
,如第 6 行所示。你被允许在一个主题上有一些变化,但是名字main
是至关重要的。一个程序只能有一个main
,并且名字必须全部用小写字母拼写。定义必须以int
开头。
Note
有几本书教你使用void
。那些书是错的。如果你必须说服某人void
是错的而int
是对的,让怀疑者参考 C++ 标准的[basic.start.main]部分。
现在,在名字main
后面使用空括号。
下一行启动主程序。注意这些语句是如何在花括号({
和}
)内分组的。C++ 就是这样对语句分组的。新手的一个常见错误是在阅读程序时省略了一个花括号或者看不到花括号。如果您习惯于更冗长的语言,如 Pascal、Ada 或 Visual Basic,您可能需要一些时间来熟悉更简洁的 C++ 语法。这本书会给你很多练习的机会。
修改第 6 行,用大写字母(MAIN)
)拼写main
。试着编译程序。会发生什么?
编译器可能会接受程序,但链接器会抱怨。您能否看出编译器和链接器之间的区别取决于您的特定工具。尽管如此,您还是没能创建一个有效的程序,因为您必须有一个main
。只有main
这个名字比较特别。就编译器而言,MAIN
只是另一个名字,就像min
或max
。因此,你不会得到一个错误消息说你拼错了main
,只是说main
不见了。拥有一个名为MAIN
的函数的程序并没有错,但是要成为一个完整的程序,你必须确保包含定义main
。
变量定义
第 8 行到第 11 行定义了一些变量。每行的第一个词是变量的类型。下一个词是变量名。该名称后面可选地跟着一个花括号中的初始值。type int
是整数的简称,bool
是布尔的简称。
Note
布尔以数理逻辑的发明者乔治·布尔的名字命名。因此,一些语言将这种类型命名为logical
。不清楚为什么 C++ 等语言对以 Boole 命名的类型使用bool
而不是boole
。
名称std::numeric_limits
是 C++ 标准库的一部分,允许您查询内置算术类型的属性。您可以确定类型所需的位数、十进制位数、最小值和最大值等。把你好奇的类型放在尖括号里。(在 C++ 中,你会经常看到这种使用类型的方法。)因此,您也可以查询std::numeric_limits<bool>::min()
并得到结果false
。
如果您要查询 bool 中的位数,您会得到什么结果?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
尝试编译并运行清单 2-3 ,看看是否正确。
import <iostream>;
import <limits>;
int main()
{
// Note that "digits" means binary digits, i.e., bits.
std::cout << "bits per bool: " << std::numeric_limits<bool>::digits << '\n';
}
Listing 2-3.Determining the Number of Bits in a bool
你得到你期望的价值了吗?如果不是,你明白为什么结果是 1 吗?
声明
清单 2-1 的第 12 行包含一条while
语句。第 15、17 和 21 行开始了if
语句。它们有相似的语法:两个语句都以关键字开始,后面是括号中的布尔条件,再后面是一个语句。语句可以是简单的语句,比如第 16 行的赋值,也可以是花括号内的语句列表。注意,一个简单的语句以分号结束。
赋值(第 14、16 和 18 行)使用了一个等号。为了清楚起见,当我大声读程序或自己读程序时,我喜欢把等号读成“得到”例如,“x 得到 min。”
当条件为真时,while
循环执行其相关语句。在执行语句之前会测试条件,因此如果条件第一次为假,语句将永远不会执行。
在第 12 行,条件是输入操作。它从标准输入(std::cin
)中读取一个整数,并将该整数存储在变量x
中。只要一个值成功存储在x
中,该条件就为真。如果输入格式不正确,或者如果程序到达输入流的末尾,则逻辑条件变为假,循环终止。
if
语句后面可以跟一个else
分支;您将在未来的探索中看到示例。
第 21 行的条件由一个名字组成:any
。因为它有类型bool
,所以可以直接作为条件使用。
修改第 15 行,将语句改为“if (x)
”。这种错误有时会在你不小心的时候出现(我们都时不时会不小心)。你编译程序的时候预计会发生什么?
你对编译器没有抱怨感到惊讶吗?运行该程序时,您预计会发生什么?
如果您向程序提供以下输入,您希望输出什么?
0 1 2 3
如果您向程序提供以下输入,您希望输出什么?
3 2 1 0
解释正在发生的事情。
C++ 对于它所允许的条件是宽容的。任何数字类型都可以是条件,编译器将非零值视为真,将零视为假。换句话说,它提供了一个隐式≠ 0 来测试数值。
许多 C 和 C++ 程序员利用这些语言提供的简洁性,但我发现这是一种草率的编程实践。始终确保您的条件在本质上是符合逻辑的,即使这意味着使用与零的显式比较。比较≠的 C++ 语法是!=
,和x != 0
一样。
输出
输出操作符是<<
,你的程序通过导入<iostream>
得到。您可以打印变量值、字符串、单个字符或计算表达式。
用单引号将单个字符括起来,例如'X'
。当然,有时您可能需要在输出中包含一个单引号。要打印单引号,必须用反斜杠(\'
)对引号字符进行转义。对字符进行转义会指示编译器将其作为标准字符处理,而不是作为程序语法的一部分。其他转义字符可以跟在反斜杠后面,比如\n
表示换行符(即,一个神奇的字符序列开始一个新的文本行;输出中的实际字符取决于主机操作系统)。要打印反斜杠字符,将其转义:'\\'
。一些字符的例子包括:'x'
、'#'
、'7'
、'\\'
、'\n'
。
如果您想一次打印多个字符,请使用用双引号括起来的字符串。要在字符串中包含双引号,请使用反斜杠转义:
std::cout << "not quoted; \"in quotes\", not quoted";
单个 output 语句可以使用多次出现的<<
,如第 22 行所示,或者您可以使用多个 output 语句。唯一的区别是可读性。
修改清单 2-3 来试验不同风格的输出。尝试使用多个 output 语句。
当一个if
语句的主体包含多个语句时,记得使用花括号。
看到了吧!我告诉过你你能读懂 C++ 程序。现在你要做的就是填补一些关于细节的知识空白。下一篇文章从基本的算术运算符开始。
三、整数表达式
在 Exploration 2 中,您检查了一个定义了一些变量并对它们执行一些简单操作的程序。这篇文章介绍了基本的算术运算符。阅读清单 3-1 ,然后回答后面的问题。
1 /// Read the program and determine what the program does.
2
3 import <iostream>;
4
5 int main()
6 {
7 int sum{0};
8 int count{};
9
10 int x;
11 while (std::cin >> x)
12 {
13 sum = sum + x;
14 count = count + 1;
15 }
16
17 std::cout << "average = " << sum / count << '\n';
18 }
Listing 3-1.Integer Arithmetic
清单 3-1 中的程序是做什么的?
使用以下输入测试程序:
10 50 20 40 30
第 7 行和第 8 行将变量sum
和count
初始化为零。你可以在花括号中输入任意整数值来初始化一个变量(第 7 行);该值不必是常量。您甚至可以将花括号留空,将变量初始化为合适的默认值(例如,bool
用false
,而int
用 0),如第 8 行所示。如果没有花括号,变量就不会被初始化,所以程序唯一能做的就是给变量赋值,如第 10 行所示。通常,不初始化变量是一个坏主意,但是在这种情况下,x
是安全的,因为第 11 行通过从标准输入中读取立即向其中填充一个值。
第 13 行和第 14 行显示了加法(+
)和赋值(=
)的例子。加法遵循计算机算术的正常规则(我们稍后会担心溢出)。赋值的工作方式和任何过程语言一样。
因此,您可以看到清单 3-1 从标准输入中读取整数,将它们相加,并打印由除法(/
)运算符计算的平均值。还是真的?
清单 3-1 怎么了?
尝试在没有输入的情况下运行程序,即在启动程序后立即按下文件结束键。一些操作系统有一个“空”文件,可以作为输入流提供。当程序从空文件中读取时,输入流总是看到文件结束条件。在类似 UNIX 的操作系统上,运行以下命令行:
list0301 < /dev/null
在 Windows 上,空文件称为NUL
,所以键入
list0301 < NUL
会发生什么?
C++ 不喜欢被零除吧?每个平台的反应都不一样。大多数系统都会以某种方式指示错误状态。少数悄悄给你垃圾结果。无论哪种方式,你都得不到任何有意义的东西。
通过引入一个if
语句来修复程序。不要担心这本书还没有涵盖if
语句。我相信你能找出如何确保这个程序避免被零除。在这里写下修正后的程序:
现在试试你的新程序。你的修复成功了吗?
将您的解决方案与清单 3-2 进行比较。
1 /// Read integers and print their average.
2 /// Print nothing if the input is empty.
3
4 import <iostream>;
5
6 int main()
7 {
8 int sum{0};
9 int count{};
10
11 int x;
12 while (std::cin >> x)
13 {
14 sum = sum + x;
15 count = count + 1;
16 }
17
18 if (count != 0)
19 std::cout << "average = " << sum / count << '\n';
20 }
Listing 3-2.Print Average, Testing for a Zero Count
记住!=
是运算符≠的 C++ 语法。因此,当count
不为零时,count != 0
为真,这意味着程序已经从其输入中读取了至少一个数字。
假设您使用以下输入运行程序:
2 5 3
你期望的输出是什么?
试试看。实际产量是多少?
你得到你所期望的了吗?有些语言对整数除法和浮点除法使用不同的运算符。C++(像 C 一样)使用相同的运算符,并根据上下文来决定执行哪种除法。如果两个操作数都是整数,则结果也是整数。
如果输入是,你期望什么
2 5 4
试试看。实际产量是多少?
整数除法将结果向零截断,因此 C++ 表达式5 / 3
等于4 / 3
等于1
。
其他算术运算符是-表示减法,*
表示乘法,%
表示余数。C++ 没有求幂运算符。
清单 3-3 向用户询问整数,并告诉用户数字是偶数还是奇数。(不用管输入具体是怎么工作的;探索号 5 将会报道这个。)完成第 11 行。
1 /// Read integers and print a message that tells the user
2 /// whether the number is even or odd.
3
4 import <iostream>;
5
6 int main()
7 {
8
9 int x;
10 while (std::cin >> x)
11 if ( ) // Fill in the condition.
12 std::cout << x << " is odd.\n";
13 else
14 std::cout << x << " is even.\n";
15 }
Listing 3-3.Testing for Even or Odd Integers
测试你的程序。你做对了吗?
我希望您使用了类似这样的一行代码:
if (x % 2 != 0)
换句话说,如果一个数除以 2 后有一个非零余数,那么这个数就是奇数。
你知道!=
比较的是不平等。你认为应该如何写一个等式比较?尝试颠倒奇数和偶数消息的顺序,如清单 3-4 所示。完成第 11 行的条件。
1 /// Read integers and print a message that tells the user
2 /// whether the number is even or odd.
3
4 import <iostream>;
5
6 int main()
7 {
8
9 int x;
10 while (std::cin >> x)
11 if ( ) // Fill in the condition.
12 std::cout << x << " is even.\n";
13 else
14 std::cout << x << " is odd.\n";
15 }
Listing 3-4.Testing for Even or Odd Integers
为了测试相等性,使用两个等号(==
)。在这种情况下:
if (x % 2 == 0)
新的 C++ 程序员,尤其是那些习惯了 SQL 和类似语言的程序员,经常犯的一个错误是使用单个等号进行比较。在这种情况下,编译器通常会提醒您出现错误。继续尝试,看看编译器会做什么。当你在第 11 行使用一个等号时,编译器会发出什么信息?
一个等号是赋值运算符。因此,C++ 编译器认为您试图将值 0 赋给表达式x % 2
,这是无意义的,编译器正确地告诉您这一点。
如果要测试x
是否为零怎么办?修改清单 3-1 当 count
为零时打印一条信息。一旦你得到正确的程序,它应该看起来像清单 3-5 。
1 /// Read integers and print their average.
2 /// Print nothing if the input is empty.
3
4 import <iostream>;
5
6 int main()
7 {
8 int sum{0};
9 int count{};
10
11 int x;
12 while (std::cin >> x)
13 {
14 sum = sum + x;
15 count = count + 1;
16 }
17
18 if (count == 0)
19 std::cout << "No data.\n";
20 else
21 std::cout << "average = " << sum / count << '\n';
22 }
Listing 3-5.Print Average, Testing for a Zero Count
现在修改清单 3-5 ,在第 18 行使用一个等号。你的编译器发出了什么消息?
大多数现代编译器认识到这种常见错误并发出警告。严格来说,代码是正确的:条件将零赋给count
。回想一下,条件 0 意味着 false,所以程序总是打印No data.
,不管它实际读取了多少数据。
如果您的编译器没有发出警告,请阅读编译器的文档。您可能需要启用一个开关来打开额外的警告,比如“可能使用赋值来代替比较”或者“条件总是为假”
正如你所看到的,使用整数很容易,也不足为奇。然而,文本有点复杂,您将在下一篇文章中看到。
四、字符串
在前面的探索中,您使用带引号的字符串作为每个输出操作的一部分。在这个探索中,你将开始学习如何通过对字符串做更多的处理来使你的输出更有趣。从阅读清单 4-1 开始。
import <iostream>;
int main()
{
std::cout << "Shape\tSides\n" << "-----\t-----\n";
std::cout << "Square\t" << 4 << '\n' <<
"Circle\t?\n";
}
Listing 4-1.Different Styles of String Output
预测清单 中程序的输出 4-1 。你可能已经知道\t
是什么意思了。如果是这样的话,这个预测很容易做出。如果你不知道,猜一猜。
现在检查你的答案。你是对的吗?那么 \t
是什么意思呢?
在字符串内部,反斜杠(\
)是一个特殊的,甚至是神奇的字符。它改变了后面字符的含义。您已经看到了\n
如何开始一个新行。现在您知道了\t
是一个水平制表符:也就是说,它将随后的输出对齐到一个制表符位置。在典型的控制台中,每八个字符位置设置一个制表位。
应该如何打印字符串中的双引号字符?
写一个程序来测试你的假设,然后运行程序。你是对的吗?
将您的程序与清单 4-2 进行比较。
import <iostream>;
int main()
{
std::cout << "\"\n";
}
Listing 4-2.Printing a Double-Quote Character
在这种情况下,反斜杠将特殊字符转换为普通字符。C++ 可以识别其他一些反斜杠字符序列,但这三个是最常用的。(当你读到《探索》中的角色时,你会学到更多。)
现在修改清单 4-1 以将三角形添加到形状列表中。
输出是什么样的?制表符不会自动对齐一列,而只是将输出定位在下一个制表符位置。要对齐列,您必须控制输出。一种简单的方法是使用多个制表符,如清单 4-3 所示。
1 import <iostream>;
2
3 int main()
4 {
5 std::cout << "Shape\t\tSides\n" <<
6 "-----\t\t-----\n";
7 std::cout << "Square\t\t" << 4 << '\n' <<
8 "Circle\t\t?\n"
9 "Triangle\t" << 3 << '\n';
10 }
Listing 4-3.Adding a Triangle and Keeping the Columns Aligned
我在列表 4-3 中捉弄了你。仔细观察第 8 行的结尾和第 9 行的开头。请注意,该程序缺少一个输出操作符(<<
),该操作符通常用于分隔所有输出项。只要有两个(或更多)相邻的字符串,编译器就会自动将它们合并成一个字符串。这个技巧只适用于字符串,不适用于字符。因此,你可以用许多不同的方式写第 8 行和第 9 行,意思完全一样。
std::cout << "\nCircle\t\t?\n" "Triangle\t" << 3 << '\n';
std::cout << "\nCircle\t\t?\nTriangle\t" << 3 << '\n';
std::cout << "\n" "Circle" "\t\t?\n" "Triangle" "\t" << 3 << '\n';
选择你最喜欢的风格,坚持下去。我喜欢在每一个新行之后做一个清晰的分隔,这样阅读我的程序的人就可以清楚地区分每一行的结束和新的一行的开始。
您可能会问自己,为什么我要麻烦地分别打印数字,而不是打印一个大字符串。这个问题问得好。在真正的程序中,打印单个字符串是最好的,但在本书中,我想不断提醒您可以用各种方式编写输出语句。例如,想象一下,如果你事先不知道一个形状的名称和它的边数,你会怎么做。也许这些信息存储在变量中,如清单 4-4 所示。
1 import <iostream>;
2 import <string>;
3
4 int main()
5 {
6 std::string shape{"Triangle"};
7 int sides{3};
8
9 std::cout << "Shape\t\tSides\n" <<
10 "-----\t\t-----\n";
11 std::cout << "Square\t\t" << 4 << '\n' <<
12 "Circle\t\t?\n";
13 std::cout << shape << '\t' << sides << '\n';
14 }
Listing 4-4.Printing Information That Is Stored in Variables
字符串的类型是std::string
。你必须在程序的顶部附近有import <string>
来通知编译器你正在使用std::string
类型。第 6 行展示了如何给一个字符串变量一个初始值。有时,您希望变量以空开始。你认为如何定义一个空的字符串变量?
写一个程序来测试你的假设。
如果在验证字符串是否真的为空时遇到困难,请尝试在两个其他非空字符串之间打印该字符串。清单 4-5 给出了一个例子。
1 import <iostream>;
2 import <string>;
3
4 int main()
5 {
6 std::string empty;
7 std::cout << "|" << empty << "|\n";
8 }
Listing 4-5.Defining and Printing an Empty String
将您的程序与清单 4-5 进行比较。你更喜欢哪个?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
为什么呢?
第 6 行没有为变量empty
提供初始值。您在 Exploration 3 中了解到,省略初始值会导致变量未初始化,这将是一个错误,因为没有其他值被赋给empty
。不允许打印或访问未初始化变量的值。但是std::string
不同。在这种情况下,缺少初始化式与空括号是一样的;也就是说,变量被初始化为一个空字符串。
当你定义一个没有初始值的字符串变量时,C++ 保证这个字符串最初是空的。修改清单 4-4 所以 shape
和 sides
变量未初始化。预测程序的输出。
发生了什么事?解释一下。
你的程序应该如清单 4-6 所示。
1 import <iostream>;
2 import <string>;
3
4 int main()
5 {
6 std::string shape;
7 int sides;
8
9 std::cout << "Shape\t\tSides\n" <<
10 "-----\t\t-----\n";
11 std::cout << "Square\t\t" << 4 << '\n' <<
12 "Circle\t\t?\n";
13 std::cout << shape << '\t' << sides << '\n';
14 }
Listing 4-6.Demonstrating Uninitialized Variables
当我运行清单 4-6 时,我会得到不同的答案,这取决于我使用的编译器和平台。大多数编译器会发出警告,但仍然会编译程序,所以你可以运行它。我得到的答案之一是这样的:
Shape Sides
----- -----
Square 4
Circle ?
4226851
用另一个平台上的另一个编译器,最后的数字是0
。然而,另一个编译器的程序打印出最后的数字-858993460
。有些系统甚至会崩溃,而不是打印出shape
或sides
的值。
这难道不奇怪吗?如果没有为类型为std::string
的变量提供初始值,C++ 会确保该变量以初始值开始,即空字符串。另一方面,如果变量的类型是int
,你无法判断初始值实际上是什么,事实上,你甚至无法判断程序是否会运行。这就是所谓的未定义行为。该标准允许 C++ 编译器和运行时环境在遇到某些错误情况时做任何事情,绝对是任何事情,比如访问未初始化的变量。
C++ 的一个设计目标是,如果可以避免,编译器和库不应该做任何额外的工作。只有程序员知道什么值作为变量的初始值是有意义的,所以赋予初始值必须是程序员的责任。毕竟,当你正在对你的天气模拟器进行最后的润色时(这将最终解释为什么当我去海滩时总是下雨),你不希望内部循环被一个浪费的指令所负担。性能保证的另一面是程序员的额外负担,以避免出现导致未定义行为的情况。一些语言帮助程序员避免问题,但是这种帮助总是伴随着性能的损失。
那么std::string
是怎么回事?简而言之,复杂类型(如字符串)不同于简单的内置类型。对于std::string
这样的类型,C++ 库提供一个定义良好的初始值其实更简单。标准库中大多数有趣的类型都有相同的行为方式。
如果你不记得什么时候定义一个没有初始值的变量是安全的,为了安全起见,使用空括号:
std::string empty{};
int zero{};
我建议初始化每个变量,即使你知道程序很快就会覆盖它,比如我们之前使用的输入循环。以“性能”的名义省略初始化很少能提高性能,而且总是会损害可读性。接下来的探索展示了初始化每个变量的重要性。
OLD-FASHIONED INITIALIZATION
初始化所有变量的大括号风格是在 C++ 11 中引入的,所以早于 C++ 11 的代码(或由在 C++ 11 之前学习 C++ 并且还没有掌握新的初始化风格的程序员编写的新代码)使用不同的方法来初始化变量。
例如,初始化整数的常用方法是使用等号。它看起来像一个赋值语句,但它不是。它定义并初始化一个变量。
int x = 42;
您也可以使用括号:
int x(42);
许多标准库类型也是如此:
std::string str1 = "sample";
std::string str2("sample");
有些类型需要括号,并且没有等号。其他类型在 C++ 11 之前使用花括号。等号、括号和花括号都有不同的规则,初学者很难理解等号和括号在初始化时的细微差别。
因此,标准化委员会努力在 C++ 11 中定义一个单一的、统一的初始化风格,他们不得不在 C++ 14 中进行调整。不过,您还没有完全走出困惑区,因为您将会看到一些需要等号进行初始化的上下文,或者大括号不像您预期的那样工作。但是普通变量应该总是使用花括号,这是我在这次探索中提出的。
五、简单输入
到目前为止,探索主要集中在产量上。现在是时候把注意力转向输入了。给定输出操作符是<<
,你期望输入操作符是什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
这不用火箭科学家来推断,是吗?输入操作符是>>
,输出操作符的反方向。可以把操作符想象成指向信息流动方向的箭头:从输入流到变量,或者从变量到输出流。
清单 5-1 显示了一个执行输入和输出的简单程序。
import <iostream>;
int main()
{
std::cout << "Enter a number: ";
int x;
std::cin >> x;
std::cout << "Enter another number: ";
int y;
std::cin >> y;
int z{x + y};
std::cout << "The sum of " << x << " and " << y << " is " << z << "\n";
}
Listing 5-1.Demonstrating Input and Output
清单 5-1 从标准输入中读取多少个数字?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
假设您输入42
和21
作为两个输入值。你对产量有什么期望?
现在运行程序,检查你的预测。希望你拿到了63
。假设您键入以下内容作为输入:
42*21
你预测会有什么样的产出?
测试你的假设。实际产量是多少?
你看到发生了什么吗?如果没有,尝试将xyz
作为程序的输入。试试42-21
。试试42.21
。
这个程序展示了两个你必须理解的不同的行为。首先,要读取一个int
,输入流必须包含一个有效的整数。整数可以以符号(-
或+
)开头,但其后必须全是数字;不允许中间有空白。当输入操作到达不能作为有效整数一部分的第一个字符(如*
)时,输入操作停止。如果从输入流中读取了至少一个数字,则读取成功,输入文本被转换为整数。如果输入流不是以有效的整数开头,则读取失败。如果读取失败,则不修改输入变量。
第二种行为是你在之前的探索中发现的;未初始化的int
变量导致未定义的行为。换句话说,如果读取失败,变量包含垃圾,或者更糟。例如,当您学习浮点数时,您将了解到未初始化的浮点变量中的一些位模式会导致程序终止。在一些专门的硬件上,未初始化的整数也可以做到这一点。这个故事的寓意是,使用未初始化的变量会导致未定义的行为。那很糟糕。所以不要做。
因此,当输入为xyz
时,两次读取都失败,并导致未定义的行为。您可能会看到这两个数字的垃圾值。当输入为42-21
时,第一个数字为42
,第二个数字为-21
,所以结果是正确的。但是当输入的是42.21
时,第一个数字是42
,第二个数字是垃圾,因为整数不能以点开头(.
)。
一旦输入操作失败,所有后续的输入尝试也将失败,除非您采取补救措施。这就是为什么如果第一个数字无效,程序不会等待你输入第二个数字。C++ 可以告诉你什么时候输入操作失败,所以你的程序可以避免使用垃圾值。此外,您可以重置流的错误状态,以便在处理错误后继续读取。我将在以后的探索中介绍这些技术。现在,确保您的输入是有效和正确的。
当你的程序没有初始化变量时,一些编译器会警告你,但是为了安全起见,最好始终初始化每个变量。如您所见,即使程序立即尝试在变量中存储一个值,也可能不会成功,这可能会导致意外的行为。
你想过整数会这么复杂吗?当然,字符串更简单,因为不需要解释它们或转换它们的值。让我们看看它们是否真的比整数简单。清单 5-2 类似于清单 5-1 ,但是它将文本读入std::string
变量。
import <iostream>;
import <string>;
int main()
{
std::cout << "What is your name? ";
std::string name{};
std::cin >> name;
std::cout << "Hello, " << name << ", how are you? ";
std::string response{};
std::cin >> response;
std::cout << "Good-bye, " << name << ". I'm glad you feel " << response << "\n";
}
Listing 5-2.Reading Strings
清单 5-2 显然不是人工智能的模型,但它很好地演示了一件事。假设输入如下:
Ray Lischner
Fine
你期望的结果是什么?
运行程序,测试你的假设。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
解释。
尝试不同的输入,并尝试辨别 C++ 用来在输入流中分隔字符串的规则。准备好了吗?去吧。我会一直等到你做完。
这么快就回来了?C++ 如何在输入流中分隔字符串?
任何空白字符(空白字符的确切列表取决于您的实现,但通常包括空格、制表符、换行符等)都会结束一个字符串,至少就输入操作而言是这样。具体来说,C++ 跳过前导空白字符。然后它累积非空格字符形成字符串。字符串在下一个空白字符处结束。
那么当你混合整数和字符串时会发生什么呢?编写一个程序,要求输入一个人的名字(仅名字)和年龄(年龄),然后将输入回显到标准输出中。你想先要哪个?阅读后打印信息。
表 5-1 显示了您的程序的一些样本输入。在每一个旁边,写下你对程序输出的预测。然后运行程序,写出实际输出。
表 5-1。
姓名和年龄的输入示例
|投入
|
预测产量
|
实际输出
|
| --- | --- | --- |
| Ray44
| | |
| 44Ray
| | |
| Ray 44
| | |
| 44 Ray
| | |
| Ray44
| | |
| 44Ray
| | |
| 44-Ray
| | |
| Ray-44
| | |
把标准输入想象成一串字符。不管用户如何输入这些字符,程序都会看到它们一个接一个地出现。(好吧,它们通过缓冲区负载大量到达,但这是一个次要的实现细节。就你而言,你的程序一次读取一个字符,这个字符来自缓冲区,而不是实际的输入设备,这并不重要。)因此,程序总是保持流中当前位置的概念。下一个读取操作总是从该位置开始。
在开始任何输入操作之前,如果输入位置处的字符是空白字符,则程序跳过(即,读取并丢弃)该字符。它一直读取并丢弃字符,直到到达一个非空格字符。然后开始实际的读取。
如果程序试图读取一个整数,它会在输入位置抓取字符,并检查它对一个整数是否有效。否则,读取失败,并且输入位置不移动。否则,输入操作将保留该字符和所有后续字符,它们是整数的有效元素。输入操作将文本解释为整数,并将值存储在变量中。因此,在读取一个整数后,您知道输入位置指向的字符是而不是一个有效的整数字符。
读取字符串时,从流中抓取所有字符,直到到达一个空白字符。因此,字符串变量不包含任何空白字符。如前所述,下一个读操作将跳过空白。
当用户关闭控制台或终端时,或者当用户键入特殊的击键序列来告诉操作系统结束输入时(如 UNIX 上的 Ctrl+D 或 DOS 或 Windows 上的 Ctrl+Z),输入流在文件的末尾结束(如果从文件中读取)。一旦到达输入流的末尾,所有后续的读取尝试都将失败。这就是导致循环在探索 2 中结束的原因。
清单 5-3 显示了我的名字优先程序版本。当然,你们的计划在细节上会有所不同,但基本大纲应该与你们的一致。
import <iostream>;
import <string>;
int main()
{
std::cout << "What is your name? ";
std::string name{};
std::cin >> name;
std::cout << "Hello, " << name << ", how old are you? ";
int age{};
std::cin >> age;
std::cout << "Good-bye, " << name << ". You are " << age << " year";
if (age != 1)
std::cout << 's';
std::cout << " old.\n";
}
Listing 5-3.Getting the User’s Name and Age
现在修改程序,颠倒姓名和年龄的顺序,再次尝试所有输入值。解释你观察到的现象。
当输入操作由于畸形输入而失败时,流进入错误状态;例如,当程序试图读取一个整数时,输入流包含字符串“Ray”。所有后续读取流的尝试都会导致错误,而不会真正尝试读取。即使流随后尝试读取一个字符串(否则会成功),错误状态也是粘滞的,字符串读取也会失败。
换句话说,当程序不能读取用户的年龄时,它也不能读取名字。这就是为什么程序会把名字和年龄都写对,或者都写错。
清单 5-4 显示了我版本的年龄优先计划。
import <iostream>;
import <string>;
int main()
{
std::cout << "How old are you? ";
int age{};
std::cin >> age;
std::cout << "What is your name? ";
std::string name{};
std::cin >> name;
std::cout << "Good-bye, " << name << ". You are " << age << " year";
if (age != 1)
std::cout << 's';
std::cout << " old.\n";
}
Listing 5-4.Getting the User’s Age and Then Name
表 5-2 显示了每种情况下输出的截断版本(只有姓名和年龄)。
表 5-2。
用 C++ 方式解释输入
|投入
|
先说名字
|
年龄第一
|
| --- | --- | --- |
| Ray44
| "Ray44"
,0
| 0
,""
|
| 44Ray
| 44Ray"
,0
| 44
,"Ray"
|
| Ray 44
| "Ray"
,44
| 0
,""
|
| 44 Ray
| "44"
,0
| 44
,"Ray"
|
| Ray44
| "Ray"
,44
| 0
,""
|
| 44Ray
| "44"
,0
| 44
,"Ray"
|
| 44#Ray
| "44#Ray"
,0
| 44
,"#Ray"
|
| Ray#44
| "Ray#44"
,0
| 0
,""
|
处理输入流中的错误需要一些更高级的 C++,但是处理代码中的错误是您现在可以处理的事情。接下来的探索将帮助您解开编译器错误消息。
六、错误消息
到目前为止,您已经看到了来自 C++ 编译器的大量错误消息。毫无疑问,有些是有用的,有些是神秘的——有些两者都是。这个探索展示了一些常见的错误,并让您有机会看到编译器会针对这些错误发出什么类型的消息。你对这些信息越熟悉,你将来就越容易理解它们。
通读清单 6-1 并留意错误。
1 #include <iosteam>
2 // Look for errors
3 int main()
4
5 std::cout < "This program prints a table of squares.\n";
6 "Enter the starting value for the table: ";
7 int start{0};
8 std::cin >> start;
9 std::cout << "Enter the ending value for the table: ";
10 int end(start);
11 std::cin << endl
12 std::cout << "# #²\n";
13 int x{start};
14 end = end + 1; // exit loop when x reaches end
15 while (x != end)
16 {
17 std:cout << x << " " << x*x << "\n";
18 x = x + 1;
19 }
20 }
Listing 6-1.Deliberate Errors
你希望编译器检测出哪些错误?
下载源代码并编译清单 [6-1 。
你的编译器实际上会发出什么消息?
创建三个组:您正确预测的消息、您预期但编译器没有发出的消息,以及编译器发出但您没有预期的消息。每组有多少条消息?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
如果您使用命令行工具,预计会在屏幕上看到大量错误。如果您使用 IDE,它将有助于收集错误消息,并将每条消息与编译器认为是错误原因的源代码中的相关点相关联。编译器并不总是正确的,但是它的提示通常是一个很好的起点。
编译器通常将问题分为两类:错误和警告。错误会阻止编译器生成输出文件(目标文件或程序)。警告告诉你有问题,但不会阻止编译器产生它的输出。现代编译器非常擅长检测有问题但有效的代码并发出警告,所以要养成留意警告的习惯。事实上,我建议提高对警告的敏感度。查看编译器的文档,寻找指导编译器检测尽可能多的警告的选项。对于 g++ 和 clang++,开关是-Wall
。Visual Studio 用的是/Wall
。另一方面,有时编译器会出错,某些警告是没有帮助的。你通常可以禁用一个特定的警告,比如 g++ 的我的最爱:-Wno-unused-local-typedefs
或者 Visual Studio 的/wd4514
。
这个程序实际上包含了七个错误,但是如果您错过了它们,请不要担心。让我们一个一个来。
拼错
第 1 行将<iostream>
拼错为<iosteam>
。你的编译器应该给你一个简单的消息,通知你它找不到<iosteam>
。编译器可能不知道您想要键入<iostream>
,所以它不会给你任何建议。你必须知道标题名称的正确拼写。
大多数编译器在这一点上完全放弃了。如果您遇到这种情况,请修复这个错误,然后再次运行编译器以查看更多消息。
如果您的编译器试图继续,它会在没有来自拼错的头文件的声明的情况下继续。在这种情况下,<iostream>
声明了std::cin
和std::cout
,因此编译器还会发出关于这些名称未知的消息,以及关于输入和输出操作符的其他错误消息。
虚假字符
最有趣的错误是在第 4 行中使用了方括号字符([
)而不是大括号字符({
)。一些编译器可能能够猜出您的意思,这可以限制产生的错误消息。其他人则不能,他们给出的信息可能相当隐晦。例如,g++ 发布了许多错误,但没有一个直接指向错误。相反,它会发出许多消息,从以下内容开始:
list0601.cpp:6:13: error: no match for 'operator<' (operand types are 'std::ostream' {aka 'std::basic_ostream<char>'} and 'const char [41]')
6 | std::cout < "This program prints a table of squares.\n";
| ~~~~~~~~~ ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| | |
| | const char [41]
| std::ostream {aka std::basic_ostream<char>}
In file included from /usr/include/c++/9/bits/stl_algobase.h:64,
from /usr/include/c++/9/bits/char_traits.h:39,
from /usr/include/c++/9/ios:40,
from /usr/include/c++/9/ostream:38,
from /usr/include/c++/9/iostream:39,
from list0601.cpp:2:
/usr/include/c++/9/bits/stl_pair.h:454:5: note: candidate: 'template<class _T1, class _T2> constexpr bool std::operator<(const std::pair<_T1, _T2>&, const std::pair<_T1, _T2>&)'
454 | operator<(const pair<_T1, _T2>& __x, const pair<_T1, _T2>& __y)
| ^~~~~~~~
/usr/include/c++/9/bits/stl_pair.h:454:5: note: template argument deduction/substitution failed:
list0601.cpp:6:15: note: 'std::ostream' {aka 'std::basic_ostream<char>'} is not derived from 'const std::pair<_T1, _T2>'
6 | std::cout < "This program prints a table of squares.\n";
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
当您无法理解错误消息时,请查看第一条消息及其标识的行号。搜索行号处或附近的错误。忽略其余的消息。
在其他行上,您可能会看到一两个错误。然而,在您修复它们之后,大量的消息仍然存在。这意味着您仍然没有找到真正的罪魁祸首(在第 4 行)。不同的编译器发出不同的消息。例如,clang++ 发出类似的消息,但是格式不同。
list0601.cpp:6:13: error: invalid operands to binary expression ('std::ostream' (aka 'basic_ostream<char>') and 'const char [41]')
std::cout < "This program prints a table of squares.\n";
~~~~~~~~~ ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
/usr/lib64/gcc/x86_64-suse-linux/9/../../../../include/c++/9/system_error:208:3: note: candidate function not viable: no known conversion from 'std::ostream' (aka 'basic_ostream<char>') to 'const std::error_code' for 1st argument
operator<(const error_code& __lhs, const error_code& __rhs) noexcept
^
一旦找到方括号并将其改为花括号,您可能会得到完全不同的消息。这是因为用[
代替{
会让编译器彻底糊涂,它无法理解程序的其余部分。纠正这个问题可以让编译器把程序理顺,但是现在它可能会发现一系列新的错误。
未知运算符
输入和输出操作符(>>
和<<
)和其他任何 C++ 操作符没有什么不同,比如加法(+
)、乘法(*
)或者比较(比如>
)。每个运算符都有一组有限的允许操作数。例如,您不能“添加”两个 I/O 流(如std::cin + std::cout
),也不能使用输出操作符将一个数字“写入”一个字符串(如"text" << 3
)。
在第 5 行,一个错误是使用了<
而不是<<
。编译器无法确定您打算使用<<
,而是发出一条消息指出<
有什么问题。确切的消息取决于编译器,但最有可能的是,该消息并不能帮助您解决这个特殊的问题。
一旦修复了操作符,请注意编译器不会为另一个错误(即多余的分号)发出任何消息。严格来说,不是 C++ 错误。这是一个逻辑错误,但结果是一个有效的 C++ 程序。有些编译器会发出警告,建议您第 6 行什么也不做,这是您犯了一个错误的暗示。其他编译器会默默地接受程序。
发现这种错误的唯一可靠的方法是学会校对你的代码。
未知名称
编译器容易发现的一个错误是,当你使用了一个编译器根本无法识别的名字。在这种情况下,偶然键入字母l
而不是分号会产生名称endl
而不是end;
。编译器会发出一条关于这个未知名称的明确消息。
修复分号,现在编译器报错另一个操作符。这一次,您应该能够放大问题,并注意到运算符面向错误的方向(<<
而不是>>
)。然而,编译器可能不会提供太多帮助。一个编译器产生如下形式的错误:
list0601.cxx
list0601.cxx(11) : error C2784: 'std::basic_ostream<char,_Traits> &std::operator
<<(std::basic_ostream<char,_Traits> &,unsigned char)' : could not deduce
template argument for 'std::basic_ostream<char,_Elem> &' from 'std::istream'
C:\Program Files\Microsoft Visual C++ Toolkit 2003\include\ostream(887)
: see declaration of 'std::operator`<<''
list0601.cxx(11) : error C2784: 'std::basic_ostream<char,_Traits> &std::operator
<<(std::basic_ostream<char,_Traits> &,unsigned char)' : could not deduce
template argument for 'std::basic_ostream<char,_Elem> &' from 'std::istream'
C:\Program Files\Microsoft Visual C++ Toolkit 2003\include\ostream(887)
: see declaration of 'std::operator`<<''
行号告诉你去哪里找,但是要靠你自己去找问题。
符号误差
但是现在你遇到了一个奇怪的问题。编译器抱怨说它不知道名字的意思(第 17 行的cout
),但是你知道它的意思。毕竟剩下的程序使用std::cout
没有任何困难。第 17 行有什么问题导致编译器忘记?
在 C++ 中,小错误会产生深远的后果。事实证明,单冒号和双冒号的意思完全不同。编译器将std:cout
视为一个标记为std
的语句,后跟一个简单的名字cout
。至少错误消息会将您指向正确的位置。然后由您来决定是否注意到丢失的冒号。
错误中的乐趣
在您修复了所有的语法和语义错误之后,编译并运行程序,以确保您真正找到了它们。然后引入一些新的错误,看看会发生什么。以下是一些建议:
试着在一条语句的末尾去掉一个分号。会发生什么?
尝试从字符串的开头或结尾删除双引号。会发生什么?
试将 int
拼错为 iny
。会发生什么?
现在我要你自己去探索。一次引入一个错误,看看会发生什么。试着一次犯几个错误。有时,错误有办法掩盖彼此。去狂野吧!玩得开心!你的老师多久鼓励你犯一次错误?
现在是时候回到正确的 C++ 代码了。下一个探索引入了for
循环。
七、更多循环
探索 2 和 3 展示了一些简单的while
循环。这个探索引入了while
循环的老大哥,for
循环。
有界循环
您已经看到了while
循环,它从标准输入读取数据,直到没有更多输入可用。这是一个典型的无界循环。除非您事先确切知道输入流将包含什么,否则您无法确定循环的界限或限制。有时你预先知道循环必须运行多少次;也就是说,你知道循环的边界,使它成为一个有界的循环。for
循环是 C++ 实现有界循环的方式。
让我们从一个简单的例子开始。清单 7-1 显示了一个打印前十个非负整数的程序。
import <iostream>;
int main()
{
for (int i{0}; i != 10; i = i + 1)
std::cout << i << '\n';
}
Listing 7-1.Using a for Loop to Print Ten Non-negative Numbers
for
循环在一个小空间里塞满了大量信息,所以一步一步来。括号内是循环的三个部分,用分号分隔。你觉得这三件是什么意思?
这三个部分是初始化、条件和后迭代。仔细看看每个部分。
初始化
第一部分看起来类似于变量定义。它定义了一个名为i
的int
变量,初始值为 0。一些受 C 启发的语言只允许初始化表达式,而不允许变量定义。在 C++ 中,你有一个选择:表达式或定义。将循环控制变量定义为初始化的一部分的好处是,您不会意外地在循环之外引用该变量。在初始化部分定义循环控制变量的缺点是,您不能故意在循环之外引用该变量。清单 7-2 展示了限制回路控制变量的优势。
import <iostream>;
int main()
{
for (int i{0}; i != 10; i = i + 1)
std::cout << i << '\n';
std::cout << "i=" << i << '\n'; // error: i is undefined outside the loop
}
Listing 7-2.You Cannot Use the Loop Control Variable Outside the Loop
限制循环控制变量的另一个后果是,您可能在多个循环中定义和使用相同的变量名,如清单 7-3 所示。
import <iostream>;
int main()
{
std::cout << '+';
for (int i{0}; i != 20; i = i + 1)
std::cout << '-';
std::cout << "+\n|";
for (int i{0}; i != 3; i = i + 1)
std::cout << ' ';
std::cout << "Hello, reader!";
for (int i{0}; i != 3; i = i + 1)
std::cout << ' ';
std::cout << "|\n+";
for (int i{0}; i != 20; i = i + 1)
std::cout << '-';
std::cout << "+\n";
}
Listing 7-3.Using and Reusing a Loop Control Variable Name
清单 7-3 产生什么作为输出?
如果不必执行任何初始化,可以将初始化部分留空,但仍然需要分号来分隔空初始化和条件。
情况
中间部分遵循与while
循环条件相同的规则。正如您所料,它控制着循环的执行。当条件为真时,循环体执行。如果条件为假,循环终止。如果循环第一次运行时条件为假,则循环体永远不会执行(但初始化部分总是执行)。
有时您会看到一个for
循环,其中缺少一个条件。这意味着条件总是为真,所以循环会不停地运行。编写始终为真的条件的更好方法是显式地使用true
作为条件。这样,任何将来必须阅读和维护您的代码的人都会明白,您故意将循环设计为永远运行。可以把它想象成一个注释:“这个条件故意留空白。”
后迭代
最后一部分看起来像一个语句,尽管它缺少结尾的分号。其实并不是完整的陈述,只是一种表达。表达式在循环体之后(因此命名为 post 迭代)和再次测试条件之前被求值。你可以把任何你想要的东西放在这里,或者留空。通常,for
循环的这一部分控制迭代,根据需要推进循环控制变量。
一个for
循环如何工作
控制流程如下:
-
初始化部分只运行一次。
-
测试条件。如果为 false,则循环终止,程序继续执行循环体后面的语句。
-
如果条件为真,则执行循环体。
-
后迭代部分执行。
-
控制跳转到 2。
轮到你了
现在轮到你写一个for
循环了。清单 7-4 展示了一个 C++ 程序的框架。填写缺失的部分,计算整数之和 从 10 到 20,包括 10 和 20。
import <iostream>;
int main()
{
int sum{0};
// Write the loop here.
std::cout << "Sum of 10 to 20 = " << sum << '\n';
}
Listing 7-4.Compute Sum of Integers from 10 to 20
在测试你的程序之前,你必须首先确定你如何知道程序是否正确。换句话说,10 到 20 的整数之和是多少,含 10 和 20?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
好,现在编译并运行你的程序。你的程序产生了什么答案?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 你的程序正确吗? _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
将您的程序与清单 7-5 中所示的程序进行比较。
import <iostream>;
int main()
{
int sum{0};
for (int i{10}; i != 21; i = i + 1)
sum = sum + i;
std::cout << "Sum of 10 to 20 = " << sum << '\n';
}
Listing 7-5.Compute Sum of Integers from 10 to 20 (Completed)
for
循环的用途是格式化和打印信息表。要实现这一点,您需要比目前所学的更好地控制输出格式。这将是下一次探索的主题。
八、格式化输出
在 Exploration 4 中,您使用制表符整齐地排列输出。标签有用,但很粗糙。本文介绍了 C++ 提供的一些特性,可以很好地格式化输出,比如设置输出字段的对齐、填充和宽度。C++ 20 提供了两种非常不同的格式化输出的方法。本章介绍了这两种方式,您可以选择自己喜欢的方式。
问题
这种探索开始有点不同。你必须编写自己的程序来解决问题,而不是阅读一个程序并回答关于它的问题。任务是打印从 1 到 20 的整数的正方形和立方体(算术变体,而不是几何形状)的表格。程序的输出应该如下所示:
N N² N³
1 1 1
2 4 8
3 9 27
4 16 64
5 25 125
6 36 216
7 49 343
8 64 512
9 81 729
10 100 1000
11 121 1331
12 144 1728
13 169 2197
14 196 2744
15 225 3375
16 256 4096
17 289 4913
18 324 5832
19 361 6859
20 400 8000
为了帮助你开始,清单 8-1 给出了一个框架程序。你只需要填充循环体。
import <iomanip>;
import <iostream>;
int main()
{
std::cout << " N N² N³\n";
for (int i{1}; i != 21; ++i)
{
// write the loop body here
}
}
Listing 8-1.Print a Table of Squares and Cubes
这是一个棘手的问题,所以如果你有困难,不要担心。这个练习的目的是演示格式化输出实际上有多难。如果你已经学到了那么多,即使你没有完成程序,你也成功地完成了这个练习。也许你一开始尝试过使用制表符,但那会使数字在左边对齐。
N N² N³
1 1 1
2 4 8
3 9 27
4 16 64
5 25 125
6 36 216
7 49 343
8 64 512
9 81 729
10 100 1000
左对齐不是我们平时写数字的方式。传统上,数字应该右对齐(或者在小数点上对齐,如果适用的话——在本文后面的相关部分“对齐”中有更多的介绍)。右对齐的数字更容易阅读。
C++ 提供了一些简单但强大的技术来格式化输出。要格式化乘幂表,必须为每一列定义一个字段。字段具有宽度、填充字符和对齐方式。以下部分深入解释了这些概念。
字段宽度
在探索如何指定对齐方式之前,首先您必须知道如何设置输出字段的宽度。我在清单 8-1 中给了你提示。有什么暗示?
节目第一行是import <iomanip>;
,你没见过。这个头声明了一些有用的工具,包括std::setw()
,它设置输出字段的最小宽度。例如,要打印一个至少占据三个字符位置的数字,调用std::setw(3)
。如果这个数字需要更多的空间,比如说 314159,那么实际的输出将会占用更多的空间。在这种情况下,间距变成了六个字符位置。
要使用setw
,调用函数作为输出语句的一部分。该语句看起来像是在试图打印setw
,但实际上什么都没有打印出来,您所做的只是操纵输出流的状态。这就是为什么setw
被称为 I/O 机械手的原因。<iomanip>
头声明了几个操纵器,您将在适当的时候了解到。
清单 8-2 显示了功率表程序,使用setw
设置表中每个字段的宽度。
import <iomanip>;
import <iostream>;
int main()
{
std::cout << " N N² N³\n";
for (int i{1}; i != 21; ++i)
std::cout << std::setw(2) << i
<< std::setw(6) << i*i
<< std::setw(7) << i*i*i
<< '\n';
}
Listing 8-2.Printing a Table of Powers the Right Way
表格的第一列需要两个位置,以容纳多达 20 的数字。第二列需要一些列与列之间的空间,以及最多容纳 400 个数字的空间;setw(6)
在N
和N²
列之间使用三个空格,数字使用三个字符位置。最后一列也使用列间三个空格和四个字符位置,允许最多 8000 个数字。
默认的字段宽度是零,这意味着您打印的所有内容都会占用它所需的确切空间,不多也不少。
打印一个项目后,字段宽度自动重置为零。例如,如果您想对整个表使用统一的六列宽度,您不能调用一次setw(6)
就让它保持不变。相反,您必须在每次输出操作之前调用setw(6)
,如下所示:
std::cout << std::setw(6) << i
<< std::setw(6) << i*i
<< std::setw(6) << i*i*i
<< '\n';
填充字符
默认情况下,值用空格字符(' '
)填充。您可以将填充字符设置为您选择的任何字符,例如零('0'
)或星号('*'
)。清单 8-3 展示了在打印支票的程序中两个填充字符的奇特用法。
import <iomanip>;
import <iostream>;
int main()
{
using namespace std;
int day{14};
int month{3};
int year{2006};
int dollars{42};
int cents{7};
// Print date in USA order. Later in the book, you will learn how to
// handle internationalization.
cout << "Date: " << setfill('0') << setw(2) << month
<< '/' << setw(2) << day
<< '/' << setw(2) << year << '\n';
cout << "Pay to the order of: CASH\n";
cout << "The amount of $" << setfill('*') << setw(8) << dollars << '.'
<< setfill('0') << setw(2) << cents << '\n';
}
Listing 8-3.Using Alternative Fill Characters
注意,与setw
不同的是,setfill
是粘性的。也就是说,输出流会记住填充字符,并将该字符用于所有输出字段,直到您设置了不同的填充字符。
标准前缀
清单 8-3 中的另一个新特性是声明using namespace std;
。所有这些前缀有时会使代码难以阅读。名字的重要部分在混乱中消失了。通过用using namespace std;
开始你的程序,你是在指示编译器把它不能识别的名字当作是以std::
开始的。
如关键字所示,std
被称为名称空间。几乎标准库中的每个名字都是std
名称空间的一部分。不允许向std
名称空间添加任何东西,任何第三方库供应商也不允许。因此,如果你看到std::
,你就知道接下来的是标准库的一部分(所以你可以在任何可靠的参考资料中查找)。更重要的是,您知道您在自己的程序中发明的大多数名称不会与标准库中的任何名称冲突,反之亦然。名称空间将您的名字与标准库名分开。在本书的后面,您将学习创建自己的名称空间,这有助于组织库和管理大型应用程序。
另一方面,using namespace std;
是一个危险的声明,我很少使用。如果没有在每个标准库名前面加上std::
限定符,就会导致混乱。例如,想象一下,如果你的程序定义了一个名为cout
或setw
的变量。编译器有解释名字的严格规则,一点也不会混淆,但是人类读者肯定会混淆。不管有没有using namespace std;
,最好避免与标准库中的名字冲突。
对齐
C++ 允许您将输出字段向左或向右对齐。如果你想集中一个数字,你只能靠自己。要强制向左或向右对齐,请使用left
和right
操纵器,包括<iostream>
后可免费获得。(唯一需要<iomanip>
的时候是当你想使用需要额外信息的操纵器,比如setw
和setfill
。)
默认的对齐方式是向右,这可能会让你觉得奇怪。毕竟,第一次尝试使用制表符来对齐表列会产生左对齐的值。然而,就 C++ 而言,它对你的表一无所知。对齐在字段内。setw
操纵器指定宽度,对齐方式决定填充字符是添加在值之后(左对齐)还是值之前(右对齐)。输出流没有它之前可能打印过的其他值的记忆(比如在前一行)。因此,例如,如果您想要将一列数字按小数点对齐,您必须手动完成(或者确保列中的每个值在小数点后都有相同的位数)。
探索格式
现在您已经了解了格式化输出字段的基本知识,是时候稍微探索一下,帮助您全面理解字段宽度、填充字符和对齐方式是如何相互作用的。读取清单 8-4 中的程序并预测其输出。
import <iomanip>;
import <iostream>;
int main()
{
using namespace std;
cout << '|' << setfill('*') << setw(6) << 1234 << '|' << '\n';
cout << '|' << left << setw(6) << 1234 << '|' << '\n';
cout << '|' << setw(6) << -1234 << '|' << '\n';
cout << '|' << right << setw(6) << -1234 << '|' << '\n';
}
Listing 8-4.Exploring Field Width, Fill Character, and Alignment
您期望清单 8-4 的输出是什么?
现在编写一个程序,它将产生以下输出。不作弊,简单打印一长串。相反,只打印整数和换行符,并加入字段宽度、填充字符和对齐操作符,以获得期望的输出。
000042
420000
42
-42-
许多不同的程序可以实现相同的目标。我的程序,如清单 8-5 所示,只是许多可能性中的一种。
import <iomanip>;
import <iostream>;
int main()
{
using namespace std;
cout << setfill('0') << setw(6) << 42 << '\n';
cout << left << setw(6) << 42 << '\n';
cout << 42 << '\n';
cout << setfill('-') << setw(4) << -42 << '\n';
}
Listing 8-5.Program to Produce Formatted Output
接受参数的操纵器,如setw
和setfill
,在<iomanip>
中声明。没有参数的操纵器,如left
和right
,在<iostream>
中声明。如果你不记得了,把两个模块都包括进去。如果你包含一个你并不真正需要的模块,你不会注意到任何不同。
I’M LYING TO YOU
left
和boolalpha
机械手是<iostream>
中声明的而不是。我骗了你。它们实际上是在<ios>
中声明的。但是<iostream>
包含<ios>
,所以当你包含<iostream>
时,你会自动获得<ios>
中的所有内容。
我对你撒谎已经有一段时间了。输入运算符(>>
)实际上是在<istream>
中声明的,输出运算符(<<
)是在<ostream>
中声明的。与<ios>
一样,<iostream>
割台始终包括<istream>
和<ostream>
。因此,您可以包含<iostream>
并获得典型输入和输出所需的所有头文件。其他的头,比如<iomanip>
,不太常用,所以不是<iostream>
的一部分。
所以我并没有真的对你撒谎,只是在等你接受事实。
替代语法
我喜欢使用操纵器,因为它们简洁、清晰、易于使用。您也可以使用点运算符(.
)将函数应用于输出流对象。比如设置填充字符,可以调用std::cout.fill('*')
。fill
函数被称为成员函数,因为它是输出流类型的成员。您不能将其应用于任何其他类型的对象。只有一些类型有成员函数,并且每个类型都定义了它所允许的成员函数。任何 C++ 库参考的很大一部分都与各种类型及其成员函数有关。(输出流的成员函数与输出操作符一起在<ostream>
中声明。输入流的成员函数在<istream>
中声明。当您导入<iostream>
时,这两个模块都会自动导入。)
当设置粘性属性时,如填充字符或对齐,您可能更喜欢使用成员函数而不是操纵器。您还可以使用成员函数来查询当前填充字符、对齐和其他标志以及字段宽度,这是您无法使用操纵器完成的事情。
成员函数语法使用流对象、点(.
)和函数调用,例如cout.fill('0')
。设置对齐稍微复杂一点。清单 8-6 显示了与清单 8-5 相同的程序,但是使用了成员函数而不是操纵器。
import <iostream>;
int main()
{
using namespace std;
cout.fill('0');
cout.width(6);
cout << 42 << '\n';
cout.setf(ios_base::left, ios_base::adjustfield);
cout.width(6);
cout << 42 << '\n';
cout << 42 << '\n';
cout.fill('-');
cout.width(4);
cout << -42 << '\n';
}
Listing 8-6.A Copy of Listing 8-5, but Using Member Functions
要查询当前填充字符,调用cout.fill()
。这与您用来设置填充字符的函数名相同,但是当您不带参数调用该函数时,它会返回当前的填充字符。类似地,cout.width()
返回当前字段宽度。获取标志略有不同。您调用setf
来设置标志,例如对齐,但是您调用flags()
来返回当前标志。此时细节并不重要,但是如果你很好奇,可以参考任何相关的库参考资料。
独立地
现在是你从头开始写程序的时候了。请随意查看其他程序,以确保您拥有所有必要的部分。编写这个程序来生成一个从 1 到 10(包括 1 和 10)的乘法表,如下所示:
*| 1 2 3 4 5 6 7 8 9 10
----+----------------------------------------
1| 1 2 3 4 5 6 7 8 9 10
2| 2 4 6 8 10 12 14 16 18 20
3| 3 6 9 12 15 18 21 24 27 30
4| 4 8 12 16 20 24 28 32 36 40
5| 5 10 15 20 25 30 35 40 45 50
6| 6 12 18 24 30 36 42 48 54 60
7| 7 14 21 28 35 42 49 56 63 70
8| 8 16 24 32 40 48 56 64 72 80
9| 9 18 27 36 45 54 63 72 81 90
10| 10 20 30 40 50 60 70 80 90 100
在您完成您的程序并确保它产生正确的输出后,将您的程序与我的程序进行比较,如清单 8-7 所示。
import <iomanip>;
import <iostream>;
int main()
{
using namespace std;
int constexpr low{1}; ///< Minimum value for the table
int constexpr high{10}; ///< Maximum value for the table
int constexpr colwidth{4}; ///< Fixed width for all columns
// All numbers must be right-aligned.
cout << right;
// First print the header.
cout << setw(colwidth) << '*'
<< '|';
for (int i{low}; i <= high; i = i + 1)
cout << setw(colwidth) << i;
cout << '\n';
// Print the table rule by using the fill character.
cout << setfill('-')
<< setw(colwidth) << "" // one column's worth of "-"
<< '+' // the vert. & horz. intersection
<< setw((high-low+1) * colwidth) << "" // the rest of the line
<< '\n';
// Reset the fill character.
cout << setfill(' ');
// For each row...
for (int row{low}; row <= high; row = row + 1)
{
cout << setw(colwidth) << row << '|';
// Print all the columns.
for (int col{low}; col <= high; col = col + 1)
cout << setw(colwidth) << row * col;
cout << '\n';
}
}
Listing 8-7.Printing a Multiplication Table
我猜你写的程序和我写的有一点不同,或者你写的很不一样。没关系。最有可能的是,您为表格规则使用了一个硬编码的字符串(分隔标题和表格的线),或者您使用了一个for
循环。我使用了 I/O 格式,只是为了向您展示什么是可能的。打印具有非零字段宽度的空字符串是打印单个字符的重复的一种快速而简单的方法。
另一个我额外增加的新特性是constexpr
关键字。在定义中使用该关键字将对象定义为常量而不是变量。编译器确保您不会意外地将任何内容赋给该对象。如你所知,命名常量比在源代码中添加数字更容易阅读和理解。
format
功能
C++ 20 中的新功能是format()
函数,它的工作方式类似于 Python 的format()
函数。第一个参数是格式字符串,后续参数是要格式化的值。该函数知道如何格式化所有内置类型,以及标准库中的类型,并且您可以为您的自定义类型定义格式化程序。
在格式字符串中,每个要格式化的值或字段由一组花括号指定。大括号外的文本被逐字复制。格式字符串后跟要作为附加函数参数打印的值。例如,
std::format("x: {}, y: {}, z: {}\n", 10, 20, 30)
返回以下字符串:
x: 10, y: 20, z: 30
您也可以从零开始给字段编号。如果格式字符串中的顺序与参数的顺序不匹配,这将非常有用。例如,
std::format("x: {2}, y: {1}, z: {0}\n", 10, 20, 30)
返回以下字符串:
x: 30, y: 20, z: 10
不要在一个格式字符串中混合编号和未编号的字段。
为了更好地控制格式,请在冒号后指定格式细节。细节取决于参数的类型。对于标准类型,格式说明符由以下部分组成。所有部分都是可选的,但如果有,必须按以下顺序排列:
fill-and-align sign # 0 width type
说明符(如果有)以可选的填充和对齐开始。开始调整时对准为'<'
,居中调整时对准为'^'
,结束调整时对准为'>'
。填充字符可以是除'{'
或'}'
之外的任何字符。如果指定填充字符,还必须指定对齐字符。默认情况下,数字是结束调整的,其他类型是开始调整的。
从左向右阅读的语言将开始调整的字段在左边对齐,结束调整的字段在右边对齐。从右向左阅读的语言会颠倒顺序,因此开始调整的字段靠右对齐,结束调整的字段靠左对齐。
在填充和对齐之后是一个可选符号:'+'
为所有数字发出一个符号,'-'
只为负数发出一个符号,或者一个空格为负数发出一个符号,一个空格为其他值发出一个空白。默认是'-'
。
接下来是一个可选的'#'
字符,用于请求另一种形式,比如一个基本前缀(0x
表示十六进制,0b
表示二进制,等等。).
接下来是字段宽度。如果不使用填充和对齐方式,可以使用“0”字符开始字段宽度,这将使用默认对齐方式,并在符号和基线后填充“0”字符。还可以通过嵌套一组花括号和一个可选的参数编号来代替字段宽度,从而将参数用作字段宽度。
最后,可选的类型字母进一步控制格式。对于整数,可以用'b'
表示二进制输出,'d'
表示十进制,'o'
表示八进制,'x'
表示十六进制,或者'c'
将值格式化为等价字符。字符的默认值是'c'
,整数的默认值是'd'
。举个例子,
std::format("'{0:c}': {0:#04x} {0:0>#10b} |{0:{1}d}| {2:s}\n", '*', 4, "str")
返回以下字符串:
'*': 0x2a 0b00101010 | 42| str
完整的规则稍微复杂一些,但是这应该足够让你开始了。使用 std::format()
功能重写清单。务必将<iomanip>
模块更改为<format>
。在清单 8-8 中比较你的程序和我的程序。
import <format>;
import <iostream>;
int main()
{
int constexpr low{1}; ///< Minimum value for the table
int constexpr high{10}; ///< Maximum value for the table
int constexpr colwidth{4}; ///< Fixed width for all columns
// First print the header.
std::cout << std::format("{1:>{0}c}|", colwidth, '*');
for (int i{low}; i <= high; i = i + 1)
std::cout << std::format("{1:{0}}", colwidth, i);
std::cout << '\n';
// Print the table rule by using the fill character.
std::cout << std::format("{2:->{0}}+{2:->{1}}\n",
colwidth, (high-low+1) * colwidth, "");
// For each row...
for (int row{low}; row <= high; row = row + 1)
{
std::cout << std::format("{1:{0}}|", colwidth, row);
// Print all the columns.
for (int col{low}; col <= high; col = col + 1)
std::cout << std::format("{1:{0}}", colwidth, row * col);
std::cout << '\n';
}
}
Listing 8-8.Printing a Multiplication Table Using the format Function
格式字符串是紧凑的,但也可能是隐晦的。选择你喜欢的风格,并在你的代码中统一使用。即使你更喜欢format()
,也要准备好阅读你那部分使用更冗长风格的代码,因为那是出现在数百万行现有 C++ 代码中的内容。
无论你如何格式化输出,循环都是你的朋友。想到循环,你首先想到的是什么数据结构?我希望您选择了数组,因为这是下一篇文章的主题。**
九、数组和向量
既然你已经了解了基础知识,是时候开始迎接更激动人心的挑战了。让我们写一个真正的程序,一些不平凡的,但仍然足够简单,以掌握这本书的早期。您的工作是编写一个程序,从标准输入中读取整数,将它们按升序排序,然后打印排序后的数字,每行一个。
在这一点上,这本书还没有涵盖足够的材料来帮助你解决这个问题,但是思考这个问题和解决它可能需要的工具是有启发性的。在这个探索中,你的第一个任务是为程序编写伪代码。尽可能地编写 C++ 代码,并编写解决问题所需的任何东西。
数组的向量
你需要一个数组来存储这些数字。只给定这么多新信息,您可以编写一个程序来读取、排序和打印数字,但只能通过手工编写排序代码来实现。那些上过大学算法课程的人可能还记得如何写冒泡排序或快速排序,但是为什么你需要去弄这么低级的代码呢?你肯定会说,有更好的办法。有:C++ 标准库有一个快速排序函数,可以对任何东西进行排序。直接跳到清单 9-1 中的解决方案。
1 import <algorithm>;
2 import <iostream>;
3 import <vector>;
4
5 int main()
6 {
7 std::vector<int> data{}; // initialized to be empty
8 int x{};
9
10 // Read integers one at a time.
11 while (std::cin >> x)
12 // Store each integer in the vector.
13 data.emplace_back(x);
14
15 // Sort the vector.
16 std::ranges::sort(data);
17
18 // Print the vector, one number per line.
19 for (int element : data)
20 std::cout << element << '\n';
21 }
Listing 9-1.Sorting Integers
该程序引入了几个新功能。让我们从第 7 行和名为vector
的类型开始,它是一个可调整大小的数组类型。下一节将向您解释。
向量
第 7 行定义了类型为std::vector<int>
的变量data
。C++ 有几种容器类型,即可以包含一堆对象的数据结构。其中一个容器是vector
,它是一个可以改变大小的数组。所有的 C++ 容器都需要一个元素类型,也就是你打算存储在容器中的对象的类型。在这种情况下,元素类型是int
。在尖括号中指定元素类型:<int>
。这告诉编译器你希望数据是一个vector
并且vector
将存储整数。
定义中缺少了什么?
向量没有大小。相反,向量可以在程序运行时增长或收缩。(如果你知道你需要一个特定的、固定大小的数组,你可以使用类型array
。在大多数程序中,你会比 ?? 更频繁地使用 ??。)由此,data
初空。和std::string
一样,vector
是一个库类型,它有一个明确定义的初始值,即空,所以如果你愿意,可以省略{}
初始化器。
您可以在向量中的任何位置插入和抹掉项目,尽管仅在末尾添加项目或仅从末尾抹掉项目时性能最佳。这就是程序在data
中存储值的方式:通过调用emplace_back
,这将一个元素添加到一个vector
的末尾(第 13 行)。向量的“后面”是末端,索引最高。“前面”是开始,所以back()
返回向量的最后一个元素,front()
返回第一个元素。如果vector
为空,不要调用这些函数;这会产生不确定的行为。您可能会发现自己经常调用的一个成员函数是size()
,它返回向量中元素的数量。
从std::
前缀可以看出,vector
类型是标准库的一部分,并没有内置到编译器中。因此,您需要import <vector>
,如第 3 行所示。没什么好惊讶的。
到目前为止提到的所有函数都是成员函数;也就是说,您必须在点运算符(.
)的左侧提供一个vector
对象,在右侧提供函数调用。另一种函数不使用点运算符,不受任何特定对象的限制。在大多数语言中,这是典型的函数,但有时 C++ 程序员称它们为自由函数,以区别于成员函数。第 16 行显示了一个自由函数的例子,std::ranges::sort
。
你如何定义一个字符串向量?
用std::string
代替int
得到std::vector<std::string>
。也可以定义一个vector
s 的vector
,是一种二维数组:std::vector<std::vector<int>>
。
范围和算法
从名字可以看出,std::ranges::sort
函数对数据进行排序。在其他一些面向对象的语言中,你可能期望vector
有一个sort()
成员函数。或者,标准库可以有一个sort
函数,该函数可以对库可以扔给它的任何东西进行排序。C++ 库属于后一类。
sort()
函数几乎可以对任何有begin()
和end()
的东西进行排序。另一个要求是能够访问数据的特定元素。要获得第三个元素,使用data.at(2)
,因为索引是从零开始的。也就是说,data.front()
类似于data.at(0)
,data.back()
类似于data.at(data.size() - 1)
。
STAY SAFE
当你阅读 C++ 程序时,你很可能会看到方括号(data[n]
)用于访问向量的元素。方括号和at
成员函数的区别在于at
函数提供了额外的安全级别。如果索引超出界限,程序将彻底终止。另一方面,对无效索引使用方括号将导致未定义的行为:您不知道会发生什么。最危险的是你的程序不会终止,而是会带着坏数据继续运行。这也是我推荐使用at
的原因。
sort()
函数可以对任意范围的数据进行排序,只要两个元素可以进行比较和排序。它有许多其他兄弟函数来对数据执行各种各样的操作,从binary_search()
可以快速找到排序向量中的值,或者shuffle()
可以将向量随机排序。
sort、binary_search 和 shuffle 函数在 C++ 标准库中被称为算法。C++ 算法可以对向量、其他容器和许多其他类型进行操作。一种算法在一个范围的数据上执行一些操作。该范围可以是一个向量,也可以只是向量的一部分。它可能根本不会存储在容器中。对范围的唯一要求是有一个开始,一个结束,以及从开始到结束的方法。
对于 vector 和其他容器,begin()
成员函数返回范围的开始,end()
成员函数返回范围的结束。begin()
返回的值被称为迭代器,因为你用它来迭代范围内的值。迭代器提供了一种间接的方法来访问范围内的值。给定一个名为iterator
的迭代器,你可以使用*iterator
来获得iterator
指向的值。++ 操作符推进了一个迭代器,因此它指向范围中的下一个值,如++iterator
所示。为了判断迭代器何时到达范围的末尾,它使用一个特殊的标记来表示范围的末尾。此标记不表示范围中的任何特定值,因此它可以标记空范围的结束。对这个标记你唯一能做的就是把它和迭代器进行比较,以确定迭代器是否到达了范围的末尾。很自然地,data.end()
返回这个特殊的结束标记,称为标记。组装这些片段会产生下面的for
循环来迭代数据元素:
for (std::vector<int>::iterator iter{data.begin()}; iter != data.end(); ++iter)
{ int element = *iter; std::cout << element << '\n'; }
这是相当多的一口。不要担心这没有任何意义,因为有一个更简单的方法。清单 9-1 第 17 行的for
循环做同样的事情,简单得多。它遍历data
的元素,并将每个后续元素赋给变量element
。因为这种类型的for
循环在一个范围内迭代,所以它通常被称为循环的范围。图 9-1 展示了data
向量的 begin 迭代器和 end sentinel 的本质。
图 9-1。
指向向量中位置的迭代器
如果 data.size()为零,data.begin()的值是多少?
没错。如果 vector 为空,data.begin()
将返回与data.end()
相同的值,该值是一个特殊的 sentinel 值,不允许取消引用。换句话说,*data.end()
导致未定义的行为。因为您可以比较两个迭代器或一个带有标记的迭代器,所以确定 vector 是否为空的一种方法是测试,如下面的代码所示:
data.begin() == data.end()
然而,更好的方法是调用data.empty()
,如果向量为空,则返回true
,如果向量至少包含一个元素,则返回false
。
除了访问向量的元素,范围和迭代器还有很多用途,从下一篇文章开始,你会在本书中经常看到它们被用于输入、输出等等。
十、算法和范围
前面的探索介绍了使用std::ranges::sort
对整数向量进行排序的向量和范围。这种探索更深入地研究了范围,并介绍了更通用的算法,这些算法对对象范围执行有用的操作。
算法
std::ranges::sort
函数是通用算法的一个例子,之所以这样命名是因为这些函数实现了通用算法并进行通用操作。也就是说,它们适用于任何可以表示为一系列值的东西。大多数标准算法都是在<algorithm>
头中声明的,尽管<numeric>
头包含一些面向数字的算法。
标准算法运行所有常见的编程活动:排序、搜索、复制、比较、修改等等。搜索可以是线性的或二进制的。包括std::ranges::sort
在内的许多函数对序列中的元素进行重新排序。不管它们做什么,几乎所有的通用算法都有一些共同的特征。(一些算法,如std::max
、std::min
和std::minmax
,对数值而不是范围进行操作。)范围有不同的风格,取决于迭代器的类型和范围数据的性质。
vector 是一个大小的范围的例子,也就是说,一个 C++ 库可以在常量时间内确定大小的范围。假设一个程序定义了从文件中读取的文本行的范围;无法预先知道行数,因此这样的范围不可能是大小合适的范围。
范围的风格也取决于迭代器的类型。C++ 有六种不同的迭代器,但是你可以把它们大致分为两类:读和写。
read 迭代器指的是值序列中的一个位置,它允许从序列中读取。大多数算法需要一个带有相应标记的读迭代器来获取输入数据。有些算法是只读的,有些算法可以修改迭代值。
大多数算法还需要一个写迭代器,通常称为输出迭代器。大多数算法只使用单一输出迭代器,而不使用输出范围。这是因为输出范围的大小不一定是已知的,直到算法已经在其输入上运行了它的过程。
如果调整了输入范围的大小,算法可以使用该信息来设置输出范围的大小,但并非所有输出范围都调整了大小。例如,将一个向量的值写入输出流有一个大小合适的输入,但没有一个大小合适的输出。为了保持算法的通用性,它们很少要求一定大小的范围作为输入,也很少接受一个范围作为输出。
因为典型的算法不会也不能检查输出迭代器的溢出,所以必须确保输出序列有足够的空间来容纳算法将要写入的所有内容。
例如,std::ranges::copy
算法将输入范围中的值复制到输出迭代器中。该函数有两个参数:输入范围和输出迭代器。您必须确保输出有足够的容量。调用resize
成员函数来设置输出向量的大小,如清单 10-1 所示。
#include <cassert>
import <algorithm>;
import <vector>;
int main()
{
std::vector<int> input{ 10, 20, 30 };
std::vector<int> output{};
output.resize(input.size());
std::ranges::copy(input, output.begin());
// Now output has a complete copy of input.
assert(input == output);
}
Listing 10-1.Demonstrating the std::ranges::copy Function
assert
函数是一种快速验证你认为是真的东西实际上是真的方法。你断言一个逻辑语句,如果你错了,程序终止,并给出一条消息来标识这个断言。assert
函数的声明不同于标准库的其余部分,使用了#include <cassert>
而不是import
。c
意味着 C++ 库从 C 标准库继承了这个头文件,#include
是 C 导入声明的方式。请注意,assert
是标准库成员以std::
开头的罕见例外之一。
如果程序是正确的,它正常运行和退出。但是如果我们犯了一个错误,断言就会触发,程序就会失败并显示一条消息。
测试清单 中的程序 10-1 。看看断言失败时会发生什么,注释掉对 std::ranges::copy
的调用,并再次运行它。写下你得到的信息。
还要注意input
的初始化。清单 10-1 展示了“通用初始化”的另一个应用(如探索 4 中所介绍的)。花括号内的逗号分隔值用于初始化向量的元素。
输出迭代器
如果输出是一个向量,能够调用resize()
是好的,但是您也可以使用输出迭代器将值写入文件或控制台。获取一个输出文件,比如std::cout
,并构造一个std::ostream_iterator<int>{std::cout}
对象,将它转换成一个输出迭代器,输出int
的值。(使用import <iterator>
获得迭代器相关声明的声明。)更好的是,您可以将一个字符串作为第二个参数传递,迭代器在它写入的每个值之后都会写入该字符串。复制清单 9-1 并用调用将数据复制到标准输出的 copy()
函数替换输出循环。
将您的程序与清单 10-2 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
int main()
{
std::vector<int> data;
int element;
while (std::cin >> element)
data.emplace_back(element);
std::ranges::sort(data);
std::ranges::copy(data, std::ostream_iterator<int>{std::cout, "\n"});
}
Listing 10-2.Demonstrating the std::ostream_iterator Class
正如您可以使用ostream_iterator
将一个范围写入标准输出,您也可以使用标准库将值从标准输入直接读入一个范围。你认为这门课叫什么?
猜得好,但是记住输入是一个范围,输出只是一个迭代器。如果把输入当作一个范围的类的名字是std::input_range
不是很好吗?但名字其实是std::ranges::istream_view
。视图是一种易于复制或分配的范围。通过将这种类型命名为视图,它告诉你可以分配一个istream_view
变量而不会导致运行时损失。
现在的工作是使用std::ranges::copy()
函数将一系列int
值从std::cin
复制到data
向量。但是这里我们遇到了一个问题,即设置data
的大小以匹配输入值的数量。emplace_back()
函数扩展了向量的大小以容纳新值,那么我们如何安排为从istream_view
中读取的每个元素调用emplace_back()
?
答案是一种特殊的输出迭代器,叫做std::back_inserter
。将data
作为参数传递给back_inserter
,写入输出迭代器的每个值都被添加到data
的末尾。现在你已经有了需要重写的清单 10-2 ,这样它就不包含任何循环,而是使用范围函数调用来完成所有的工作。
在清单 10-3 中比较你的程序和我的程序。
1 import <algorithm>;
2 import <iostream>;
3 import <iterator>;
4 import <ranges>;
5 import <vector>;
6
7 int main()
8 {
9 std::vector<int> data;
10 std::ranges::copy(std::ranges::istream_view<int>(std::cin),
11 std::back_inserter(data));
12 std::ranges::sort(data);
13 std::ranges::copy(data, std::ostream_iterator<int>{std::cout, "\n"});
14 }
Listing 10-3.Demonstrating the std::back_inserter Function
丑陋的 C++ 真相:有时需要括号有时需要花括号有时需要尖括号有时需要方括号。你怎么知道什么时候用什么?通过记忆语言和库的规则。好吧,没那么糟。方括号用于下标,尖括号用于类型。但是圆括号和花括号可能会让人非常困惑。
在 Exploration 2 中,我让你在初始化变量时使用花括号。在动态创建对象(如迭代器)时,也是如此。例如,您可以创建一个ostream_iterator
对象并将其传递给一个函数,比如copy
。因为您正在创建一个对象,所以应该使用花括号。但是back_inserter
呢?它实际上是一个函数,使用它的参数创建并返回一个back_insert_iterator
对象。通过使用其参数(data
)的类型(std::vector<int>
),back_inserter()
可以创建正确类型的back_insert_iterator
对象。这种复杂性的结果是,你需要记住什么是函数,什么是类型。
标准库包含了太多的函数,这里就不再赘述了。只是为了体验一下什么是可用的,将第 13 行的copy
函数改为unique_copy
。你认为这会如何改变程序的行为?
试试看。如果您看不出任何差异,尝试以下输入:
10 42 3 1 42 5 3 10 3
现在你可以看到当一个数字重复时,unique_copy
只复制一个值。因此,您应该会看到前面输入的以下输出:
1
3
5
10
42
我们再试试一个函数。不叫sort()
,叫reverse()
。一定要把unique_copy()
改回copy()
,因为unique_copy()
只有在输入排序后才能正常工作。名字 reverse 告诉你从程序中可以得到什么。试试看,确保你理解了。和我一样的投入,你得到了什么?
3
10
3
5
42
1
3
42
10
在探索 9 中,在引入远程for
循环之前,我向你扔了一个丑陋的for
循环只是为了吓唬你。是时候开始分解那些丑陋的代码,并理解各个部分的含义了。下一篇文章将仔细研究方便的增量(++
)操作符。
十一、递增和递减
本文介绍了递增(++
)运算符,它在 C++ 语言中有多种用途。不奇怪,它有一个对应的递减值:--
。这篇文章仔细研究了这些操作符,它们经常出现,是语言名称的一部分。
Note
我知道你 C,Java 等。自从我在探索 7 中写了i = i + 1
之后,程序员们就一直在等待这种探索。正如你在探索 9 中看到的,在 C++ 中++
操作符比你所熟悉的更有意义。所以我等到现在才讨论。
递增
操作符为 C、Java、Perl 和许多其他程序员所熟悉。c 是第一种广泛使用的语言,它引入这个运算符来表示“递增”或“加 1”C++ 扩展了它从 C 继承的用法;标准库以几种新的方式使用++
操作符,比如推进迭代器。
递增运算符有两种形式:前缀和后缀。理解这两种风格之间区别的最好方法是进行演示,如清单 11-1 所示。
import <iostream>;
int main()
{
int x{42};
std::cout << "x = " << x << "\n";
std::cout << "++x = " << ++x << "\n";
std::cout << "x = " << x << "\n";
std::cout << "x++ = " << x++ << "\n";
std::cout << "x = " << x << "\n";
}
Listing 11-1.Demonstrating the Difference Between Prefix and Postfix Increment
预测程序的输出。
实际产量是多少?
解释前缀( ++x
)和后缀( x++
)递增的区别。
简单来说,前缀运算符首先递增变量:表达式的值是递增后的值。后缀运算符保存旧值,递增变量,并将旧值用作表达式的值。
一般来说,使用前缀而不是后缀,除非你需要后缀的功能。这种差异很少是显著的,但是后缀运算符必须保存旧值的副本,这可能会带来很小的性能开销。如果不必使用后缀,为什么要付出那个代价呢?
递减
递增运算符有一个递减对应:-
-。递减运算符是减一,而不是加一。递减也有前缀和后缀的味道。前缀运算符前减,后缀运算符后减。
您可以递增和递减任何数值类型的变量;然而,只有一些迭代器允许递减。
例如,输出迭代器只向前移动。您可以使用递增运算符(前缀或后缀),但不能使用递减运算符。自己测试一下。编写一个使用std::ostream_iterator
的程序,并尝试在迭代器上使用递减运算符。(如果你需要提示,请看清单 10-3 。将ostream_iterator
对象保存在一个变量中。然后使用递减运算符。程序没意义没关系;无论如何它都不会通过编译器。)
您会得到什么样的错误信息?
不同的编译器发出不同的消息,但消息的本质应该是没有定义--
运算符。如果你需要程序方面的帮助,请参见清单 11-2 。
1 import <algorithm>;
2 import <iostream>;
3 import <iterator>;
4 import <ranges>;
5 import <vector>;
6
7 int main()
8 {
9 std::vector<int> data;
10 std::ranges::copy(std::ranges::istream_view<int>(std::cin),
11 std::back_inserter(std::cout));
12 std::ranges::sort(data);
13 std::ostream_iterator<int> output{ std::cout, "\n" };
14 --output;
15 std::ranges::copy(input, output);
16 }
Listing 11-2.Erroneous Program That Applies Decrement to an Output Iterator
在探索 10 的最后,你写了一个调用std::ranges::reverse()
函数的程序。让我们来看看这个函数是如何工作的。提示:它使用递增和递减运算符。
与其他类似的算法一样,std::ranges::reverse
函数接受一个 range 对象作为参数。它使用范围的 begin 迭代器和 end sentinel 来表示要反转的范围的界限。然后,大多数范围算法执行一点小技巧,将 end sentinel 转换为 end iterator。通过在范围的开始和结束之间创建一个对称,我们可以通过递增 begin 迭代器和递减 end 迭代器直到它们交叉路径来实现反转。其他算法不需要减少结束迭代器,但它们仍然是一个只有迭代器的函数,因为所有这些函数在 C++ 17 中都已经存在,所以通过调用 C++ 17 迭代器函数来实现 C++ 20 范围函数是很容易的。
成员类型
首先要注意的是,int
向量的迭代器类型如下:
std::vector<int>::iterator
通常对对象的成员使用点(.
)操作符,但是成员类型使用::
(称为作用域操作符),因为类型与对象不同。其他成员类型包括size_type
,它是用于存储size()
成员函数的值的类型。value_type
成员类型是范围元素的类型;对于vector
这样的容器,它是尖括号内的类型。
回到迭代器
知道您不需要键入成员类型的全名,您可能会松一口气。C++ 提供了一个快捷方式auto
。当您不需要键入完整的类型名时,请使用auto
作为类型,因为类型在上下文中是显而易见的。在这种情况下,begin()
成员函数总是返回一个迭代器。原来end()
也返回迭代器,所以我们不必学习如何将 sentinel 转换成迭代器。(这很简单,但是涉及到一些我们还没有涉及到的 C++。)在这种情况下,end()
返回一个可以被透明地视为迭代器和哨兵的类型。
换句话说,您可以定义一个变量,称为left
,它保存左侧迭代器,如下所示:
auto left{ data.begin() };
类似地,right
保存右边的迭代器,它从向量的末尾开始:
auto right{ data.end() };
然而,有一点不同。left
迭代器实际上指向了向量的一个元素(假设向量不为空),而right
没有。它指向一个结束标记。如果我们递减它,它将指向向量的最后一个元素(即back()
)。
要获得迭代器指向的值,使用*left
,这被称为解引用迭代器。因此,当以这种方式使用时,*
操作符被称为解引用操作符。
left
迭代器将递增,right
迭代器将递减,直到它们相遇。这意味着我们希望 for 循环只要left != right
不等于right
就迭代,也就是说left
不等于【】,或者它们不指向范围内的相同位置。使用迭代器时,一个常见的错误是不小心使用了解引用操作符并比较值,而不是比较位置。密切注意那些星号!
最后一步是知道如何反转两个元素,给定两个迭代器。最简单的方法是创建一个临时对象,如下所示:
auto temporary{ *left };
*left = *right;
*right = temporary;
当处理基本类型时,这可能是最快的选择。但是当您改变类型时,您不希望不得不重写您的代码。相反,使用 C++ 函数std::iter_swap()
,它交换两个迭代器指向的值。它使用我还没有介绍过的功能实现了最佳效果:
std::iter_swap(left, right);
记住这个函数交换参数指向的值,并且不改变变量left
和right
的值。
你现在已经有了你需要的所有部分。写一个程序,将整数读入一个向量,然后反转向量中元素的顺序(不调用标准库的 reverse 函数),并打印结果。
用偶数和奇数的整数测试你的程序。将您的程序与清单 11-3 中的程序进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <ranges>;
import <vector>;
int main()
{
std::vector<int> data{};
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(data));
for (auto start{data.begin()}, end{data.end()}; start != end; /*empty*/)
{
--end;
if (start != end)
{
std::iter_swap(start, end);
++start;
}
}
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 11-3.Reversing the Input Order
start
迭代器指向data
向量的开头,而end
最初指向一个超过末尾的向量。如果向量为空,for
循环终止,不执行循环体。然后循环体递减end
,使其指向向量的一个实际元素。
请注意,程序会在每次递增后仔细比较start != end
,并在每次递减操作后再次比较。如果程序只有一次比较,start
和end
就有可能互相通过。循环条件永远不会为真,程序会表现出未定义的行为,所以天会塌下来,地会吞下我,或者更糟。
还要注意for
循环有一个空的后迭代部分。迭代逻辑出现在循环体的不同位置,这不是编写循环的首选方式,但在这种情况下是必要的。
您可以重写循环,这样后迭代逻辑只出现在循环头中。一些程序员认为,在循环体中分布递增和递减会使循环更难理解,尤其是更难证明循环正确终止。另一方面,把所有东西都塞进循环头会让循环条件变得特别难以理解,正如你在清单 11-4 中看到的。
import <algorithm>;
import <iostream>;
import <iterator>;
import <ranges>;
import <vector>;
int main()
{
std::vector<int> data{};
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(data));
for (auto start{data.begin()}, end{data.end()};
start != end and start != --end;
++start)
{
std::iter_swap(start, end);
}
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 11-4.Rewriting the for Loop
为了在循环头中保留所有的逻辑,有必要使用一个新的操作符:and
。在下一篇文章中,您将会学到更多关于这个操作符的知识;同时,只要相信它实现了一个逻辑and
操作,继续读下去。
大多数有经验的 C++ 程序员可能更喜欢清单 11-4 ,而大多数初学者可能更喜欢清单 11-3 。在条件中间隐藏递减会使代码更难阅读和理解。太容易忽略递减了。然而,随着你获得 C++ 的经验,你会对递增和递减更加适应,清单 11-4 会开始让你喜欢。
Note
比起清单 11-4 ,我更喜欢清单 11-3 。我真的不喜欢在复杂的条件中隐藏递增和递减操作符。
随着你对 C++ 了解的越来越多,你会发现这个程序的其他方面也需要改进。我鼓励你重温旧程序,看看你的新技术如何简化编程任务。当我在本书中重温这些例子时,我也会这样做。
清单 11-4 引入了and
操作符。下一篇文章将更仔细地研究这个操作符,以及其他逻辑操作符和它们在条件中的使用。
十二、条件和逻辑
你第一次遇见bool
型是在探索 2 。这种类型有两个可能的值:true
和false
,它们是保留的关键字(与 C #中不同)。尽管大多数探索并不需要使用bool
类型,但许多探索在循环和if
语句条件中使用了逻辑表达式。这个探索考察了bool
类型和逻辑操作符的许多方面。
输入输出和布尔值
C++ I/O 流允许读写bool
值。默认情况下,流将它们视为数值:true
是1
,而false
是0
。操纵器std::boolalpha
(在<ios>
中声明,因此您可以从<iostream>
中免费获得)告诉一个流将bool
值解释为单词。默认的话是true
和false
。(在探索中,你会发现如何使用英语以外的语言。)您使用std::boolalpha
操纵器的方式与使用任何其他操纵器的方式相同(如您在探索 8 中所见)。对于输入流,使用带有操纵器的输入运算符。
编写一个程序,演示 C++ 如何格式化和打印 bool
数值,数字和文本。
将您的程序与清单 12-1 进行比较。
import <iostream>;
int main()
{
std::cout << "true=" << true << '\n';
std::cout << "false=" << false << '\n';
std::cout << std::boolalpha;
std::cout << "true=" << true << '\n';
std::cout << "false=" << false << '\n';
}
Listing 12-1.Printing bool Values
你认为 C++ 如何处理输入的 bool
值?
写一个程序来测试你的假设。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _解释一个输入流如何处理 bool
输入。
默认情况下,当一个输入流必须读取一个bool
值时,它实际上读取的是一个整数,如果这个整数的值是1
,那么这个流会将其解释为真。值0
为假,任何其他值都会导致错误。
使用std::boolalpha
操纵器,输入流需要精确的文本true
或false
。不允许整数,也不允许任何大小写差异。输入流只接受那些精确的单词。
使用std::noboolalpha
操纵器恢复到默认的数字布尔值。因此,您可以在单个流中混合字母和数字表示的bool
,如下所示:
bool a{true}, b{false};
std::cin >> std::boolalpha >> a >> std::noboolalpha >> b;
std::cout << std::boolalpha << a << ' ' << std::noboolalpha << b;
默认情况下,std::format()
将一个布尔值转换成一个字符串,就像boolalpha
一样。您也可以将一个bool
格式化为一个整数来格式化值0
或1
。
std::cout << std::format("{} {:d}\n", a, b);
在大多数程序中,读取或写入bool
值实际上并不经常发生。
布尔型
C++ 自动将许多不同的类型转换为bool
,因此,无论何时需要bool
,您都可以使用整数、I/O 流对象和其他值,比如在循环或if
语句条件中。你可以在清单 12-2 中看到这一点。
1 import <iostream>;
2
3 int main()
4 {
5 if (true) std::cout << "true\n";
6 if (false) std::cout << "false\n";
7 if (42) std::cout << "42\n";
8 if (0) std::cout << "0\n";
9 if (42.4242) std::cout << "42.4242\n";
10 if (0.0) std::cout << "0.0\n";
11 if (-0.0) std::cout << "-0.0\n";
12 if (-1) std::cout << "-1\n";
13 if ('\0') std::cout << "'\\0'\n";
14 if ('\1') std::cout << "'\\1'\n";
15 if ("1") std::cout << "\"1\"\n";
16 if ("false") std::cout << "\"false\"\n";
17 if (std::cout) std::cout << "std::cout\n";
18 if (std::cin) std::cout << "std::cin\n";
19 }
Listing 12-2.Automatic Type Conversion to bool
预测清单 的输出 12-2 。
检查你的答案。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
你可能被第 15 行和第 16 行忽悠了。C++ 不解释字符串文字的内容来决定是将字符串转换成true
还是false
。所有的字符串都是true
,甚至是空字符串。(C++ 语言设计者这样做并不是故意的。字符串是true
有一个很好的理由,但是你必须学习更多的 C++ 才能理解为什么。)
另一方面,字符文字(第 13 行和第 14 行)与字符串文字完全不同。编译器将数值为零的转义字符'\0'
转换为false
。其他所有角色都是true
。
回想以前的许多例子(尤其是在 Exploration 3 中),循环条件通常取决于输入操作。如果输入成功,循环条件为true
。实际发生的是 C++ 知道如何将一个流对象(比如std::cin
)转换成bool
。每个 I/O 流都跟踪其内部状态,如果任何操作失败,流都会记住这个事实。当您将一个流转换为bool
时,如果该流处于失败状态,则结果为false
。然而,并不是所有的复杂类型都可以转换成bool
。
编译并运行清单 12-3 时,您预计会发生什么?
import <iostream>;
import <string>;
int main()
{
std::string empty{};
if (empty)
std::cout << "empty is true\n";
else
std::cout << "empty is false\n";
}
Listing 12-3.Converting a std::string to bool
编译器报告一个错误,因为它不知道如何将std::string
转换为bool
。
Note
虽然istream
知道如何将输入字符串转换成bool
,但是std::string
类型缺少解释字符串所需的信息。如果不知道字符串的上下文,让字符串解释文本是不现实的,比如“true”、“vrai”或“richtig”。
那std::vector
呢?你以为 C++ 定义了 std::vector 到 bool 的转换?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _。写一个程序来测试你的假设。你的结论是什么?
这是另一个没有通用解决方案的情况。一个空的向量应该是false
,而其他的都是true
吗?也许一个只含有false
元素的std::vector<bool>
应该是false
。只有应用程序程序员才能做出这些决定,所以 C++ 库的设计者明智地选择不为您做出这些决定;因此,您无法将std::vector
转换为bool
。但是,有一些方法可以通过调用成员函数来获得想要的结果。
逻辑运算符
现实世界的条件通常比仅仅将单个值转换为bool
更复杂。为了适应这种情况,C++ 提供了常用的逻辑操作符:and
、or
和not
(这是保留的关键字)。它们具有数理逻辑中通常的含义,即and
是false
,除非两个操作数都是true
;or
是true
,除非两个操作数都是false
;并且not
反转其操作数的值。
然而,更重要的是,内置的and
和or
操作符不会计算它们右边的操作数,除非必须这样做。只有当左边的操作数是true
时,and
运算符才必须计算右边的操作数。(如果左边的操作数是false
,整个表达式就是false
,右边的操作数不用求值。)类似地,or
操作符仅在左侧操作数为true
时才计算其右侧操作数。像这样提前停止评估被称为短路。
例如,假设您正在编写一个简单的循环来检查一个向量的所有元素,以确定它们是否都等于零。当到达向量的末尾或找到不等于零的元素时,循环结束。
写一个程序,将数字读入一个向量,在向量中搜索非零元素,并打印一条关于向量是否全为零的消息。
你可以不用逻辑运算符来解决这个问题,但是试着用一个,只是为了练习。看一下清单 12-4 ,看看解决这个问题的一种方法。
1 import <algorithm>;
2 import <iostream>;
3 import <iterator>;
4 import <ranges>;
5 import <vector>;
6
7 int main()
8 {
9 std::vector<int> data{};
10 std::ranges::copy(std::ranges::istream_view<int>(std::cin),
11 std::back_inserter(data));
12
13 auto iter{data.begin()}, end{data.end()};
14 for (; iter != end and *iter == 0; ++iter)
15 /*empty*/;
16 if (iter == end)
17 std::cout << "data contains all zeroes\n";
18 else
19 std::cout << "data does not contain all zeroes\n";
20 }
Listing 12-4.Using Short-Circuiting to Test for Nonzero Vector Elements
第 14 行是关键。迭代器遍历向量并测试零值元素。
当迭代器到达向量末尾时会发生什么?
条件iter != end
在向量的末尾变成false
。因为短路,C++ 从不计算表达式的*iter == 0
部分,这很好。
为什么这样好?如果没有发生短路会发生什么?
想象iter != end
是false
;换句话说,iter
的值就是end
。这意味着*iter
就像*end
,这很糟糕——真的很糟糕。不允许取消对最后一个迭代器的引用。如果你幸运的话,它会使你的程序崩溃。如果你运气不好,你的程序会继续运行,但是会有完全不可预测的错误数据,因此会有不可预测的错误结果。
短路保证了当iter
等于end
时,C++ 不会对*iter
求值,这意味着当程序解引用iter
时,它总是有效的,这很好。有些语言(如 Ada)对短路和非短路操作使用不同的运算符。C++ 没有。内置的逻辑操作符总是执行短路操作,所以当您打算使用短路操作符时,您永远不会意外地使用非短路操作符。
老式语法
逻辑运算符有符号版本:&&
代表and
,||
代表or
,!
代表not
。关键词更清晰,更容易阅读,更容易理解,更不容易出错。没错,更不容易出错。你看,&&
就是and
的意思,但是&
也是运算符。同样,|
是一个有效的操作符。因此,如果您不小心写了&
而不是&&
,您的程序将会编译甚至运行。它可能暂时看起来运行正确,但最终会失败,因为&
和&&
意味着不同的东西。(你将在本书后面了解&
和|
。)新的 C++ 程序员不是唯一犯这个错误的人。我见过经验丰富的 C++ 程序员在表示&&
或|
而不是||
时写&
。通过仅使用关键字逻辑运算符来避免此错误。
我甚至犹豫是否要提到符号操作符,但是我不能忽略它们。许多 C++ 程序使用符号操作符,而不是等价的关键字。这些伴随着符号长大的 C++ 程序员更喜欢继续使用符号而不是关键字。这是你成为潮流引领者的机会。避开老式的、难以阅读的、难以理解的、容易出错的符号,拥抱关键词。
比较运算符
内置的比较运算符总是产生bool
结果,不管它们的操作数是什么。你已经看到了平等和不平等的==
和!=
。你也看到了小于的<
,你可以猜到>
的意思是大于。同样,你可能已经知道<=
表示小于或等于,>=
表示大于或等于。
当您将这些运算符与数字操作数一起使用时,它们会产生预期的结果。您甚至可以将它们用于数值类型的向量。
写一个程序,演示 <
如何处理 int 的向量。(如果你写程序有困难,看看清单 12-5 。)对于一个向量来说,支配 <
的规则是什么?
C++ 在元素级别比较向量。也就是说,比较两个向量的第一个元素。如果一个元素比另一个小,那么它的向量就被认为比另一个小。如果一个向量是另一个向量的前缀(即,向量在较短向量的长度内是相同的),则较短向量小于较长向量。
import <iostream>;
import <vector>;
int main()
{
std::vector<int> a{ 10, 20, 30 }, b{ 10, 20, 30 };
if (a != b) std::cout << "wrong: a != b\n";
if (a < b) std::cout << "wrong: a < b\n";
if (a > b) std::cout << "wrong: a > b\n";
if (a == b) std::cout << "okay: a == b\n";
if (a >= b) std::cout << "okay: a >= b\n";
if (a <= b) std::cout << "okay: a <= b\n";
a.emplace_back(40);
if (a != b) std::cout << "okay: a != b\n";
if (a < b) std::cout << "wrong: a < b\n";
if (a > b) std::cout << "okay: a > b\n";
if (a == b) std::cout << "wrong: a == b\n";
if (a >= b) std::cout << "okay: a >= b\n";
if (a <= b) std::cout << "wrong: a <= b\n";
b.emplace_back(42);
if (a != b) std::cout << "okay: a != b\n";
if (a < b) std::cout << "okay: a < b\n";
if (a > b) std::cout << "wrong: a > b\n";
if (a == b) std::cout << "wrong: a == b\n";
if (a >= b) std::cout << "wrong: a >= b\n";
if (a <= b) std::cout << "okay: a <= b\n";
}
Listing 12-5.Comparing Vectors
C++ 在比较std::string
类型时使用相同的规则,但在比较两个字符串文字时不使用。
编写一个程序,演示 C++ 如何通过比较两个 std::string
对象的内容来比较它们。
在清单 12-6 中将你的解决方案与我的进行比较。
import <iostream>;
import <string>;
int main()
{
std::string a{"abc"}, b{"abc"};
if (a != b) std::cout << "wrong: abc != abc\n";
if (a < b) std::cout << "wrong: abc < abc\n";
if (a > b) std::cout << "wrong: abc > abc\n";
if (a == b) std::cout << "okay: abc == abc\n";
if (a >= b) std::cout << "okay: abc >= abc\n";
if (a <= b) std::cout << "okay: abc <= abc\n";
a.push_back('d');
if (a != b) std::cout << "okay: abcd != abc\n";
if (a < b) std::cout << "wrong: abcd < abc\n";
if (a > b) std::cout << "okay: abcd > abc\n";
if (a == b) std::cout << "wrong: abcd == abc\n";
if (a >= b) std::cout << "okay: abcd >= abc\n";
if (a <= b) std::cout << "wrong: abcd <= abc\n";
b.push_back('e');
if (a != b) std::cout << "okay: abcd != abce\n";
if (a < b) std::cout << "okay: abcd < abce\n";
if (a > b) std::cout << "wrong: abcd > abce\n";
if (a == b) std::cout << "wrong: abcd == abce\n";
if (a >= b) std::cout << "wrong: abcd >= abce\n";
if (a <= b) std::cout << "okay: abcd <= abce\n";
}
Listing 12-6.Demonstrating How C++ Compares Strings
测试 C++ 如何比较带引号的字符串文字更加困难。编译器不使用字符串的内容,而是使用字符串在内存中的位置,这是编译器内部工作的细节,与任何实际工作都没有关系。因此,除非您知道编译器是如何工作的,否则您无法预测它将如何比较两个引用的字符串。换句话说,不要那样做。确保在比较字符串之前创建了std::string
对象。如果只有一个操作数是std::string
也没问题。另一个可以是带引号的字符串文字,编译器知道如何比较std::string
和文字,如下例所示:
if ("help" > "hello") std::cout << "Bad. Bad. Bad. Don’t do this!\n";
if (std::string("help") > "hello") std::cout << "this works\n";
if ("help" > std::string("hello")) std::cout << "this also works\n";
if (std::string("help") > std::string("hello")) std::cout << "and this works\n";
接下来的探索不直接涉及布尔逻辑和条件。相反,它展示了如何编写复合语句,这是编写任何有用的条件语句所需要的。
十三、复合语句
您已经在许多程序中使用了复合语句(即,用花括号括起来的语句列表)。现在是时候学习复合语句的一些特殊规则和用法了,复合语句也被称为块。
声明
C++ 有一些可怕的语法规则。相比之下,语句的语法非常简单。C++ 语法根据其他语句定义了大多数语句。例如,while
语句的规则是
while ( condition ) statement
在这个例子中,粗体元素是必需的,比如关键字while
。斜体元素代表其他语法规则。从例子中可以推断出,while
语句可以将任何语句作为循环体,包括另一个while
语句。
大多数语句似乎以分号结尾的原因是,C++ 中最基本的语句只是一个后跟分号的表达式。
expression ;
这种语句叫做表达式语句。
我还没有讨论表达式的精确规则,但是它们的工作方式和大多数其他语言一样,只是有一些不同。最重要的是,赋值是 C++ 中的一个表达式(就像在 C、Java、C#等语言中一样)。,但在 Pascal、Basic、Fortran 等语言中没有。).请考虑以下几点:
while (std::cin >> x)
sum = sum + x;
这个例子演示了一个单独的while
语句。while
语句的一部分是另一个语句:在本例中,是一个表达式语句。表情语句中的表情是sum = sum + x
。表达式语句中的表达式通常是赋值或函数调用,但是语言允许任何表达式。因此,下面是一个有效的陈述:
42;
如果你在程序中使用这个语句,你认为会发生什么?
试试看。实际发生了什么?
现代编译器通常能够检测出无用的语句,并将它们从程序中删除。通常,编译器会告诉你它在做什么,但是你可能需要提供一个额外的选项来告诉编译器要特别挑剔。例如,尝试使用 g++ 的-Wall
选项或 Microsoft Visual C++ 的/Wall
选项。(在所有警告中,那是墙,不是支撑你屋顶的东西。)
复合语句的语法规则是
{ statement* }
其中*
表示前面的规则(语句)出现了零次或多次。注意,右花括号后面没有分号。
c++ 如何解析以下内容?
while (std::cin >> x)
{
sum = sum + x;
++count;
}
同样,您有一个while
语句,因此循环体必须是一个单独的语句。在本例中,循环体是一个复合语句。复合语句是由两个表达式语句组成的语句。图 13-1 显示了相同信息的树形视图。
图 13-1。
C++ 语句的简化解析树
考虑main()
的主体,例如清单 13-1 中的主体。你看到了什么?没错,是复合语句。这是一个普通的积木,它和其他积木遵循同样的规则。如果您想知道,main()
的主体必须是一个复合语句。这是少数几种 C++ 需要特定类型的语句,而不允许任何语句的情况之一。
查找并修复清单 13-1 中的错误。通过阅读代码,直观地找到尽可能多的错误。当你认为你已经找到并解决了所有问题时,试着编译并运行这个程序。
1 import <iostream>;
2 import <vector>;
3 // find errors in this program
4 int main()
5 {
6 std::vector<int> positive_data{}, negative_data{};
7
8 for (int x{0}; std::cin >> x ;) {
9 if (x < 0);
10 {
11 negative_data.push_back(x)
12 };
13 else
14 {
15 positive_data.push_back(x)
16 }
17 };
18 }
Listing 13-1.Finding Statement Errors
记录清单 13-1 中的所有错误。
没有编译器的帮助,你都找到了吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
这些错误是
-
第 9 行多了一个分号
-
第 12 行多了一个分号
-
第 11 行和第 15 行末尾缺少分号
-
第 17 行多了一个分号
额外加分的是,哪些错误不是语法违规(编译器不会提醒你)并且不影响程序的行为?
如果你回答了“第 17 行额外的分号”,给自己一颗星。严格地说,多余的分号代表一个空的、无所事事的语句,称为空语句。这种语句有时在循环中会用到,尤其是在循环头中完成所有工作的for
循环,没有留给循环体任何事情去做。(参见清单 12-4 中的示例。)
因此,编译器解释第 9 行的方式是分号是if
语句的语句体。下一个语句是一个复合语句,后面跟一个else
,没有对应的if
,因此出现错误。每个else
必须是同一语句中前面的if
的对应。换句话说,每个if
条件后面必须紧跟一条语句,然后是可选的else
关键字和另一条语句。您不能以任何其他方式使用else
。
如前所述,第 9 行的if
语句后面是三个语句:一个空语句、一个复合语句和另一个空语句。解决方案是通过删除第 9 行和第 12 行的分号来删除 null 语句。
组成复合语句的语句可以是任何语句,包括其他复合语句。下一节将解释为什么要将一个复合语句嵌套在另一个复合语句中。
第 6 行显示您可以使用逗号分隔符一次声明多个变量。我更喜欢一次定义一个变量,但也想向您展示这种风格。每个变量都有自己的初始化器。
本地定义和范围
复合语句不仅仅是将多个语句组合成一个语句。还可以在块内对定义进行分组。您在块中定义的任何变量只在块的范围内可见。可以使用变量的区域称为变量的范围。一个好的编程实践是将范围限制在尽可能小的区域。限制变量的范围有几个目的:
-
防止错误:你不能意外地在变量名的作用域之外使用它。
-
交流意图:任何阅读你的代码的人都能知道一个变量是如何被使用的。如果在尽可能广泛的范围内定义变量,那么阅读您的代码的人必须花费更多的时间和精力来确定在哪里使用不同的变量。
-
重用名字:你能使用多少次变量
i
作为循环控制变量?只要每次将变量的作用域限制在循环中,就可以随时使用和重用它。 -
重用内存:当执行到达一个块的末尾时,该块中定义的所有变量都被销毁,内存可供再次使用。因此,如果您的代码创建了许多大型对象,但一次只需要一个,您可以在每个变量自己的范围内定义每个变量,这样一次只存在一个大型对象。
清单 13-2 展示了一些局部定义的例子。粗体突出显示的行表示本地定义。
#include <cassert>
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <vector>;
int main()
{
std::vector<int> data{};
data.insert(data.begin(), std::istream_iterator<int>(std::cin),
std::istream_iterator<int>());
// Silly way to sort a vector. Assume that the initial portion
// of the vector has already been sorted, up to the iterator iter.
// Find where *iter belongs in the already sorted portion of the vector.
// Erase *iter from the vector and re-insert it at its sorted position.
// Use binary search to speed up the search for the proper position.
// Invariant: elements in range begin(), iter are already sorted.
for (auto iter{data.begin()}, end{data.end()}; iter != end; )
{
// Find where *iter belongs by calling the standard algorithm
// lower_bound, which performs a binary search and returns an iterator
// that points into data at a position where the value should be inserted.
int value{*iter};
auto here{std::lower_bound(data.begin(), iter, value)};
if (iter == here)
++iter; // already in sorted position
else
{
iter = data.erase(iter);
// re-insert the value at the correct position.
data.insert(here, value);
}
}
// Debugging code: check that the vector is actually sorted. Do this by comparing
// each element with the preceding element in the vector.
for (auto iter{data.begin()}, prev{data.end()}, end{data.end()};
iter != end;
++iter)
{
if (prev != data.end())
assert(not (*iter < *prev));
prev = iter;
}
// Print the sorted vector all on one line. Start the line with "{" and
// end it with "}". Separate elements with commas.
// An empty vector prints as "{ }".
std::cout << '{';
std::string separator{" "};
for (int element : data)
{
std::cout << separator << element;
separator = ", ";
}
std::cout << " }\n";
}
Listing 13-2.Local Variable Definitions
清单 [13-2 有很多新的功能和特性,所以让我们一次看一部分代码。
data
的定义是一个块中的局部定义。没错,你几乎所有的定义都在这个最外层,但是复合语句就是复合语句,复合语句中的任何定义都是局部定义。这就引出了一个问题:你是否可以在所有块之外定义一个变量。答案是肯定的,但是你很少愿意。C++ 允许全局变量,但是本书中没有一个程序需要定义全局变量。当时机成熟时,我会讨论全局变量(这将是探索 52 )。
一个for
循环有自己特殊的作用域规则。正如你在《探索 7 中所学的,一个for
循环的初始化部分可以,并且经常定义一个循环控制变量。该变量的范围被限制在for
循环中,就好像for
语句被包含在一组额外的花括号中。
value
变量也是for
循环体的局部变量。如果试图在循环之外使用该变量,编译器会发出一条错误消息。在这种情况下,你没有理由在循环外使用这个变量,所以在循环内定义这个变量。
lower_bound
算法执行二分搜索法,试图在一系列排序值中找到一个值。它返回一个迭代器,该迭代器指向该值在范围中的第一个匹配项,或者如果没有找到该值,则指向可以插入该值并保持范围有序的位置。这正是这个程序排序data
向量所需要的。
成员函数从向量中删除一个元素,将向量的大小减少一。向erase
传递一个迭代器来指定要删除哪个元素,并保存返回值,这个迭代器引用向量中该位置的新值。insert
函数在迭代器指定的位置(第一个参数)之前插入一个值(第二个参数)。
注意如何使用和重用名称iter
。每个循环都有自己独特的名为iter
的变量。每一个iter
对于它的循环都是局部的。如果你写了草率的代码并且没有初始化iter
,变量的初始值将会是垃圾。它与程序中前面定义的变量不是同一个变量,所以它的值与旧变量的旧值不同。
separator
变量保存一个分隔符字符串,在打印矢量时在元素之间打印。它也是一个局部变量,但是对于main
程序的块来说是局部的。然而,通过在使用它之前定义它,您传达了这样一个信息,即在main
中不需要这个变量。它有助于防止在另一个部分重用main
的一个部分的变量时可能出现的错误。
另一种帮助限制变量范围的方法是在块内的块中定义变量,如清单 13-3 所示。(这个版本的程序用对标准算法的调用代替了循环,这是当你不想表达观点时编写 C++ 程序的一个更好的方法。)
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <vector>;
int main()
{
std::vector<int> data{};
data.insert(data.begin(), std::istream_iterator<int>(std::cin),
std::istream_iterator<int>());
std::ranges::sort(data);
{
// Print the sorted vector all on one line. Start the line with "{" and
// end it with "}". Separate elements with commas. An empty vector prints
// as "{ }".
std::cout << '{';
std::string separator{" "};
for (int element : data)
{
std::cout << separator << element;
separator = ", ";
}
std::cout << " }\n";
}
// Cannot use separator out here.
}
Listing 13-3.Local Variable Definitions in a Nested Block
大多数 C++ 程序员很少嵌套块。随着你对 C++ 了解的越来越多,你会发现各种改进嵌套块的技术,让你的main
程序看起来不那么混乱。
for 循环头中的定义
如果您没有在for
循环头中定义循环控制变量,而是在循环外定义它们,会怎么样?试试看。
重写清单 13-2 ,所以不要在 for
循环头中定义任何变量。
你怎么想呢?新代码看起来比原来的更好还是更差?_ _ _ _ _ _ _ _ _ _ _ _为什么?
就个人而言,我发现for
循环很容易变得混乱。尽管如此,将循环控制变量保持在循环的局部对于清晰性和代码理解是至关重要的。当面对一个大型的、未知的程序时,你在理解这个程序时面临的困难之一是知道变量何时以及如何呈现新的值。如果一个变量是循环的局部变量,你知道这个变量不能在循环之外被修改。那是有价值的信息。如果你仍然需要说服,试着阅读和理解清单 13-4 。
#include <cassert>
import <algorithm>;
import <iostream>;
import <iterator>;
import <ranges>;
import <string>;
import <vector>;
int main()
{
int v{};
std::vector<int> data{};
std::vector<int>::iterator i{}, p{};
std::string s{};
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(data));
i = data.begin();
while (i != data.end())
{
v = *i;
p = std::lower_bound(data.begin(), i, v);
if (i == p)
++i;
else
{
i = data.erase(i);
data.insert(p, v);
}
}
s = " ";
for (p = i, i = data.begin(); i != data.end(); p = i, ++i)
{
if (p != data.end())
assert(not (*i < *p));
}
std::cout << '{';
for (i = data.begin(); i != data.end(); ++i)
{
v = *p;
std::cout << s << v;
s = ", ";
}
std::cout << " }\n";
}
Listing 13-4.Mystery Function
嗯,这并不太难,是吧?毕竟,您最近刚刚读完清单 13-2 ,因此您可以看到清单 13-4 也打算做同样的事情,但是稍微进行了重组。困难在于跟踪p
和i
的值,并确保它们在程序的每一步都有正确的值。尝试编译并运行该程序。记录你的观察结果。
哪里出了问题?
我写错了,把v = *i
写成了v = *p
。如果您在运行程序之前发现了这个错误,那么恭喜您。如果变量被正确地定义在各自的局部范围内,这个错误就不会发生。
接下来的探索引入了文件 I/O,所以你的练习可以读写文件,而不是使用控制台 I/O,我相信你的手指会很感激。
十四、文件 I/O 简介
从标准输入读取或写到标准输出对许多普通程序来说都很好,这是 UNIX 和相关操作系统的标准习惯用法。尽管如此,真正的程序必须能够打开命名文件进行读、写或两者兼有。这篇探索介绍了文件 I/O 的基础知识。后面的探索将解决更复杂的 I/O 问题。
读取文件
在这些早期探索中,最常见的与文件相关的任务是从文件中读取,而不是从标准输入流中读取。这样做的最大好处之一是节省了大量繁琐的打字工作。有些 ide 很难重定向输入和输出,所以从文件中读取数据有时写入文件会更容易。清单 14-1 显示了一个基本程序,它从一个名为 list1401.in 的文件中读取整数,并将它们写入标准输出流,每行一个。如果程序无法打开文件,它会打印一条错误消息。
#include <cerrno>
import <algorithm>;
import <fstream>;
import <iostream>;
import <iterator>;
import <system_error>;
int main()
{
std::ifstream in{"list1401.in"};
if (not in)
std::cerr << "list1401.in: " <<
std::generic_category().message(errno) << '\n';
else
{
std::ranges::copy(std::ranges::istream_view<int>(in),
std::ostream_iterator<int>{std::cout, "\n"});
in.close();
}
}
Listing 14-1.Copying Integers from a File to Standard Output
<fstream>
模块声明了ifstream
,这是您用来从文件中读取的类型。要打开一个文件,只需在ifstream
的初始化程序中命名该文件。如果文件无法打开,ifstream
对象处于错误状态,这种情况可以使用if
语句进行测试。当你读完文件后,调用close()
成员函数。关闭流后,您将无法再从中读取内容。
一旦文件打开,就像从std::cin
读取一样读取它。在<istream>
中声明的所有输入操作符对于ifstream
都同样有效,就像它们对于std::cin
一样。
如果文件无法打开,您希望发出一个有用的错误消息,这里您进入了历史 C 和现代 C++ 之间的阴间。操作系统通常会发出各种错误代码,表明文件不存在,你没有权限阅读该文件,电磁脉冲永久扰乱了你的博士论文内容,等等。std::generic_category()
(在<system_error>
中声明)函数返回一个对象,该对象可用于获取与 POSIX 错误代码相关的信息,我们希望该信息在 C 变量中,errno
(没有前导std::
,与<cassert>
一样,您使用#include <cerrno>
)。message()
函数返回一个字符串消息,或者您可以构造一个可移植的error_code
对象。C++ 标准没有说明文件流是否或者如何在errno
中存储错误值,但是实际上期望errno
保存一个有用的值。
当你知道输入文件不存在时,运行程序。程序显示什么信息?
如果可以,创建输入文件,然后更改文件的保护,这样您就不能再读取它了。运行程序。
这次你得到了什么信息?
写文件
正如您可能已经猜到的,要写入文件,您需要定义一个ofstream
对象。要打开文件,只需在变量的初始化器中命名文件。如果该文件不存在,将会创建它。如果文件确实存在,它的旧内容将被丢弃,以准备写入新内容。如果文件无法打开,那么ofstream
对象就处于错误状态,所以在尝试使用它之前要记得测试它。像使用std::cout
一样使用ofstream
对象。
修改清单 14-1 把数字写到一个已命名的文件中。这次,将输入文件命名为 list140 2 。输入并将输出文件命名为列表 140 2 。out 。在清单 14-2 中将你的解决方案与我的进行比较。
#include <cerrno>
import <algorithm>;
import <fstream>;
import <iostream>;
import <ranges>;
import <system_error>;
int main()
{
std::ifstream in{"list1402.in"};
if (not in)
std::cerr << "list1402.in: " <<
std::generic_category().message(errno) << '\n';
else
{
std::ofstream out{"list1402.out"};
if (not out)
std::cerr << "list1402.out: " <<
std::generic_category().message(errno) << '\n';
else
{
std::ranges::copy(std::ranges::istream_view<int>(in),
std::ostream_iterator<int>{out, "\n"});
out.close();
in.close();
}
}
}
Listing 14-2.Copying Integers from a Named File to a Named File
与ifstream
一样,ofstream
类型在<fstream>
中声明。
程序首先打开输入文件。如果成功,它将打开输出文件。如果顺序颠倒,程序可能会创建输出文件,然后无法打开输入文件,结果将是一个浪费的空文件。总是先打开输入文件。
还要注意,如果程序无法打开输出文件,它不会关闭输入文件。别担心:它会很好地关闭输入文件。当in
在main
结束时被销毁,文件自动关闭。
我知道你在想什么:如果in
是自动关闭的,为什么还要调用close
?为什么不让in
在所有情况下自动关闭?对于一个输入文件,这实际上是没问题的。随意从程序中删除in.close();
语句。然而,对于输出文件,这样做是不明智的。
有些输出错误在文件关闭之前不会出现,操作系统会刷新其所有内部缓冲区,并在关闭文件时执行所有其他需要执行的清理工作。因此,在您调用close()
之前,输出流对象可能不会收到来自操作系统的错误。检测和处理这些错误是一项高级技能。发展这项技能的第一步是养成为输出文件显式调用close()
的习惯。当需要添加错误检查时,您将有一个地方可以添加它。
尝试在各种错误场景下运行清单 14-2 中的程序。创建输出文件, list140 2 。out ,然后使用操作系统将文件标记为只读。会发生什么?
如果你注意到程序没有检查输出操作是否成功,恭喜你有敏锐的眼光!C++ 提供了几种不同的方法来检查输出错误,但是它们都有缺点。最简单的是测试输出流是否处于错误状态。您可以在每次输出操作后检查流,但是这种方法很麻烦,很少有人用这种方式编写代码。另一种方法是让流在每次操作后检查错误情况,并向程序发出异常警告。你将会在探索 45 中学到这项技术。一种非常常见的技术是完全忽略输出错误。作为折衷,我建议在调用close()
之后测试错误。清单 14-3 显示了程序的最终版本。
#include <cerrno>
import <algorithm>;
import <fstream>;
import <iostream>;
import <ranges>;
import <system_error>;
int main()
{
std::ifstream in{"list1403.in"};
if (not in)
std::cerr << "list1403.in: " <<
std::generic_category().message(errno) << '\n';
else
{
std::ofstream out{"list1403.out"};
if (out) {
std::ranges::copy(std::ranges::istream_view<int>(in),
std::ostream_iterator<int>{out, "\n"});
out.close();
}
if (not out)
std::cerr << "list1403.out: " <<
std::generic_category().message(errno) << '\n';
}
}
Listing 14-3.Copying Integers, with Minimal Error-Checking
基本的 I/O 并不难,但是当您开始处理复杂的错误处理、国际问题、二进制 I/O 等等时,它很快就会变成一片粘糊糊的复杂代码的泥沼。以后的探索将会介绍这些主题中的大部分,但只是在时机成熟的时候。但是现在,回到早期的程序,练习修改它们来读写命名文件,而不是标准的输入和输出流。为了简洁起见(如果没有其他原因的话),本书中的例子将继续使用标准的 I/O 流。如果您的 IDE 干扰了标准 I/O 流的重定向,或者如果您只是喜欢命名文件,那么您现在知道如何更改示例来满足您的需求了。
十五、映射数据结构
既然你已经了解了基础知识,是时候开始更激动人心的挑战了。让我们写一个真正的程序——一些不简单但足够简单的程序,以便在本书的早期就能掌握。你的任务是写一个程序来读取单词并计算每个单词的出现频率。为了简单起见,单词是由空格分隔的一串非空格字符。但是,请注意,根据这个定义,单词最终会包含标点符号,但是我们将在以后解决这个问题。
这是一个复杂的程序,涉及到目前为止你所学的关于 C++ 的一切。如果您想练习对文件 I/O 的新理解,请从命名文件中读取。如果您喜欢简单,请阅读标准输入。在开始尝试编写程序之前,花一点时间考虑一下这个问题以及解决这个问题所需的工具。为程序写伪代码。尽可能编写 C++ 代码,并编写解决问题所需的任何其他代码。保持简单——不要纠结于试图获得正确的语法细节。
使用映射
这篇文章的标题告诉你什么样的 C++ 特性有助于为这个问题提供一个简单的解决方案。C++ 称之为映射,一些语言和库称之为字典或关联。映射只是一种数据结构,它存储成对的键和值,并按键进行索引。换句话说,它将一个键映射到一个值。在一个映射中,键是唯一的。该映射以升序存储键。因此,程序的核心是一个映射,它将字符串存储为键,将出现次数存储为每个键的关联值。
自然,你的程序需要<map>
头。映射数据类型称为std::map
。要定义映射,需要在尖括号内指定键和值的类型(用逗号分隔),如下例所示:
std::map<std::string, int> counts;
您几乎可以使用任何类型作为键和值类型,甚至是另一个映射。与vector
一样,如果您不初始化map
,它开始时为空。
使用映射的最简单方法是使用方括号查找值。例如,counts["the"]
返回与键"the"
相关联的值。如果该键不在映射中,则添加初始值零。如果值类型是std::string
,初始值将是一个空字符串。
有了这些知识,您就可以编写程序的第一部分——收集字数,如清单 15-1 所示。(你可以随意修改程序,从一个已命名的文件中读取,就像你在 14 中所学的那样。)
import <iostream>;
import <map>;
import <string>;
int main()
{
std::map<std::string, int> counts{};
std::string word{};
while (std::cin >> word)
++counts[word];
// TODO: Print the results.
}
Listing 15-1.Counting Occurrences of Unique Words
在清单 15-1 中,++
操作符递增程序存储在counts
中的计数。换句话说,当counts[word]
检索相关的值时,它会让您修改该值。您可以将它用作赋值的目标,或者应用递增或递减运算符。
例如,假设您想将计数重置为零。
counts["something"] = 0;
那很简单。现在剩下要做的就是打印结果。像 vector 一样,map 也使用范围和迭代器,但是因为迭代器引用一个键/值对,所以使用起来比 vector 的迭代器稍微复杂一些。
成对
打印映射的最佳方式是使用基于范围的for
循环来迭代映射。每个 map 元素都是包含键和值的单个对象。键叫做first
,值叫做second
。
Note
map
元素值的两个部分没有命名为key
和value
,因为std::pair
类型是 C++ 库的通用部分。库在几个不同的地方使用这种类型。因此,pair
的零件名称也是通用的,并不与map
特别相关。
使用点(.
)运算符访问pair
的成员。为了简单起见,将输出打印为键,后跟一个制表符,然后是计数,都在一行上。将所有这些部分放在一起,你最终得到了完整的程序,如清单 15-2 所示。
import <iostream>;
import <map>;
import <string>;
int main()
{
std::map<std::string, int> counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
++counts[word];
// For each word/count pair...
for (auto element : counts)
// Print the word, tab, the count, newline.
std::cout << element.first << '\t' << element.second << '\n';
}
Listing 15-2.Printing Word Frequencies
当迭代映射时,您知道您将使用.first
和.second
成员,所以对键/值对使用auto
有助于保持代码的可读性。让编译器去担心细节吧。
使用您在 Exploration 8 中获得的知识,您知道如何通过调整两个整齐的列而不是使用制表符来更好地格式化输出。所有需要做的就是找出最长密钥的大小。为了右对齐计数,您可以尝试确定最大计数所需的位数,或者您可以简单地使用一个非常大的数,比如 10。
改写清单 15-2 将输出整齐地排列起来,按最长键的大小排列。
自然,你需要写另一个循环来访问counts
的所有元素并测试每个元素的大小。在 Exploration 10 中,您了解到vector
有一个size()
成员函数,它返回向量中元素的数量。得知map
和string
也有size()
成员功能,你会惊讶吗?C++ 库的设计者尽最大努力与名字保持一致。size()
成员函数返回一个size_type
类型的整数。
将您的程序与清单 15-3 进行比较。
import <format>;
import <iostream>;
import <map>;
import <string>;
int main()
{
std::map<std::string, int> counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
++counts[word];
// Determine the longest word.
std::string::size_type longest{};
for (auto element : counts)
if (element.first.size() > longest)
longest = element.first.size();
// For each word/count pair...
constexpr int count_size{10}; // Number of places for printing the count
for (auto element : counts)
// Print the word, count, newline. Keep the columns neatly aligned.
std::cout << std::format("{1:{0}}{3:{2}}\n",
longest, element.first, count_size, element.second);
}
Listing 15-3.Aligning Words and Counts Neatly
如果你想要一些样本输入,试试文件 explore15.txt ,你可以从这本书的网站下载。注意单词是如何左对齐的,计数是如何右对齐的。我们期望数字是右对齐的,而单词习惯上是左对齐的(在西方文化中)。还记得探险中的constexpr
8 吗?这仅仅意味着count_size
是一个常量。
在映射中搜索
一个map
按照键的排序顺序存储它的数据。因此,在一个map
中搜索相当快(对数时间)。因为一个map
保持它的键有序,你可以使用任何二分搜索法算法,但是更好的是使用map
的成员函数。这些成员函数与标准算法同名,但是可以利用它们对map
内部结构的了解。成员函数也以对数时间运行,但开销比标准算法少。
例如,假设您想知道单词在输入流中出现了多少次。您可以读取输入并以通常的方式收集计数,然后调用find("the")
查看"the"
是否在map
中,如果是,获取一个指向其键/值对的迭代器。如果键不在映射中,find()
返回end()
迭代器。如果密钥存在,您可以提取计数。您已经掌握了解决这个问题所需的所有知识和技能,所以继续编写程序来打印单词**出现的次数。同样,您可以使用 explore15.txt 作为样本输入。如果不想使用重定向,修改程序从 explore15.txt 文件中读取。
**当你提供这个文件作为输入时,你的程序打印了多少计数? __________ 清单 15-4 中的程序检测到十个事件。
import <iostream>;
import <map>;
import <string>;
int main()
{
std::map<std::string, int> counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
++counts[word];
auto the{counts.find("the")};
if (the == counts.end())
std::cout << "\"the\": not found\n";
else if (the->second == 1)
std::cout << "\"the\": occurs " << the->second << " time\n";
else
std::cout << "\"the\": occurs " << the->second << " times\n";
}
Listing 15-4.Searching for a Word in a Map
到目前为止,你都是用一个点(.
)来访问一个成员,比如find()
或者end()
。迭代器是不同的。你必须使用一个箭头(->
)从迭代器中访问一个成员,因此有了the->second
。在探索 33 之前你不会经常看到这种风格。
有时你不想使用auto
,因为你想确保人类读者知道变量的类型。变量the
是什么类型?
官方类型是std::map<std::string, int>::iterator
,相当全键盘。在这种情况下,你可以看到为什么我更喜欢auto
。但是,还有另一种解决方案可以保留类型的显式使用并保持简洁感:类型同义词,这恰好是下一篇文章的主题。**
十六、类型同义词
使用像std::vector<std::string>::size_type
或std::map<std::string, int>::iterator
这样的类型可能会很笨拙,容易出现打字错误,而且打字和阅读起来非常烦人。C++ 有时会让你摆脱auto
,但并不总是如此。幸运的是,C++ 允许您为笨拙的类型定义简短的同义词。还可以使用类型同义词为泛型类型提供有意义的名称。(标准库有很多后者的同义词。)这些同义词通常被称为 typedefs,因为您可以用typedef
关键字定义它们,尽管在现代 C++ 中,using
关键字更常见。
typedef
和using
声明
C++ 从 C 继承了typedef
的基本语法和语义,所以您可能已经熟悉了这个关键字。如果是这样的话,请在我向其他读者介绍时耐心等待。
typedef
的想法是为另一种类型创建一个同义词或别名。创建类型同义词有两个令人信服的原因:
-
他们为长类型名创建了一个短同义词。例如,您可能想使用
count_iter
作为std::map<std::string,int>::iterator
的类型同义词。 -
他们创造了一个助记同义词。例如,一个程序可能将
height
声明为int
的同义词,以强调height
类型的变量存储一个高度值。这些信息有助于读者理解程序。
typedef
声明的基本语法类似于定义一个变量,除了您以typedef
关键字开始,并且类型同义词的名称代替了变量名。
typedef std::map<std::string,int>::iterator count_iter;
typedef int height;
另一种方法是使用using
关键字,在这种情况下,顺序会颠倒,为了可读性,会加上一个等号:
using count_iter = std::map<std::string, int>;
using height = int;
重新查看清单 15-4 并通过使用typedef
或using
声明来简化程序。将您的结果与清单 16-1 进行比较。
import <iostream>;
import <map>;
import <string>;
int main()
{
using count_map = std::map<std::string,int>;
using count_iterator = count_map::iterator;
count_map counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
++counts[word];
count_iterator the{counts.find("the")};
if (the == counts.end())
std::cout << "\"the\": not found\n";
else if (the->second == 1)
std::cout << "\"the\": occurs " << the->second << " time\n";
else
std::cout << "\"the\": occurs " << the->second << " times\n";
}
Listing 16-1.Counting Words, with a Clean Program That Uses using
我喜欢这个节目的新版本。这是这个小程序中的一个小差别,但是它提供了额外的清晰度和可读性。现在我想向你展示一个新的 C++ 特性。将清单 16-1 与清单 16-2 进行比较。
import <iostream>;
import <map>;
import <string>;
int main()
{
using count_map = std::map<std::string,int>;
using count_iterator = count_map::iterator;
count_map counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
++counts[word];
if (count_iterator the{counts.find("the")}; the == counts.end())
std::cout << "\"the\": not found\n";
else if (the->second == 1)
std::cout << "\"the\": occurs " << the->second << " time\n";
else
std::cout << "\"the\": occurs " << the->second << " times\n";
}
Listing 16-2.Counting Words, Moving a Definition Inside an if Statement
区别很小:变量the
的定义现在嵌入在if
语句的条件中。这种变化告诉编译器和人类读者,条件变量the
仅限于if
语句及其else
部分。在程序末尾增加一行:
auto this_does_not_work{ the };
编译新程序。会发生什么?
编译器发出一个错误,因为变量the
在if
语句之外不可用。在诸如本书中的小程序中,这种差异可能看起来并不显著,但是在实际的程序中,为了避免错误和误解,将变量限制在尽可能小的范围内是至关重要的。
常见类型定义
正如您已经看到的,标准库大量使用了 typedefs。例如,std::vector<int>::size_type
是整数类型的 typedef。你不知道是哪种整数类型(C++ 有几种,你会在 Exploration 26 中了解到),也没关系。你所要知道的是,如果你想在一个变量中存储一个大小或索引,那么size_type
就是要使用的类型。
最有可能的是,size_type
是std::size_t
的 typedef,而后者本身也是 typedef。std::size_t
typedef 是适合表示大小的整数类型的同义词。特别是,C++ 有一个运算符sizeof
,它返回类型或对象的字节大小。sizeof
的结果是一个std::size_t
类型的整数;然而,编译器作者选择实现sizeof
和std::size_t
。
Note
一个“字节”被定义为类型char
的大小。所以,根据定义,sizeof(char)
== 1。其他类型的大小取决于实现。在大多数流行的桌面工作站上,sizeof(int)
== 4,但是 2 和 8 也是可能的候选者。
现在让我们回到计单词的问题上来。这个程序有许多可用性缺陷。
你能想到什么方法来改进字数统计程序?
在我的清单上,最重要的是以下两项:
-
忽略标点符号。
-
忽略大小写差异。
为了实现这些额外的特性,你必须学习更多的 C++。例如,C++ 标准库具有测试字符是标点符号、数字、大写字母、小写字母等等的功能。接下来的探索从更近距离地探索人物开始。
十七、字符
在 Exploration 2 中,我向您介绍了单引号中的字符文字,例如'\n'
,以结束一行输出,但是我还没有花时间来解释这些基本的构件。现在是更深入地探索角色的时候了。
字符类型
char
类型代表单个字符。在内部,所有计算机都将字符表示为整数。字符集定义了字符和数值之间的映射。常见的字符集是 ISO 8859-1(也称为 Latin-1)和 ISO 10646(与 Unicode 相同),但许多其他字符集也在广泛使用。
C++ 标准并不强制要求任何特定的字符集。文字'4'
代表数字 4,但计算机内部使用的实际值取决于实现。您不应该假设任何特定的字符集。例如,在 ISO 8859-1 (Latin-1),'4'
的值为 52,但在 EBCDIC 中,它的值为 244。
同样,给定一个数值,您不能对该值所代表的字符做任何假设。如果你知道一个char
变量存储值 169,那么这个字符可能是'z'
(EBCDIC)、'©'
(Unicode),或者'
љ'
(iso 8859-5)。
C++ 并没有试图隐藏字符实际上是一个数字的事实。您可以将char
值与int
值进行比较,将char
赋给int
变量,或者用char
s 进行算术运算。例如,C++ 保证您的编译器和库支持的任何字符集都表示具有连续值的数字字符,从'0'
开始。因此,举例来说,以下对于所有 C++ 实现都是正确的:
'0' + 7 == '7'
字母表中的字母也是如此,即'A' + 25 == 'Z'
和'q' - 'm' == 4
,但是 C++ 不保证'A'
和'a'
的相对值。
阅读清单 17-1 。这个程序是做什么的?(提示:get
成员函数从流中读取一个字符。它不跳过空白或特殊对待任何字符。额外提示:如果你从一个你知道是数字的字符中减去'0'
会发生什么?)
import <iostream>;
int main()
{
int value{};
bool have_value{false};
char ch{};
while (std::cin.get(ch))
{
if (ch >= '0' and ch <= '9')
{
value = ch - '0';
have_value = true;
while (std::cin.get(ch) and ch >= '0' and ch <= '9')
value = value * 10 + ch - '0';
}
if (ch == '\n')
{
if (have_value)
{
std::cout << value << '\n';
have_value = false;
}
}
else if (ch != ' ' and ch != '\t')
{
std::cout << '\a';
have_value = false;
while (std::cin.get(ch) and ch != '\n')
/*empty*/;
}
}
}
Listing 17-1.Working and Playing with Characters
简而言之,这个程序从标准输入中读取数字,并将数值回显到标准输出中。如果程序读取到任何无效字符,它会警告用户(使用\a
,我将在后面的探索中描述),忽略输入行,并丢弃值。允许前导和尾随空格和制表符。程序仅在到达输入行的末尾后打印保存的数值。这意味着如果一行包含多个有效数字,程序只打印最后一个值。为了保持代码简单,我忽略了溢出的可能性。
get
函数接受一个字符变量作为参数。它从输入流中读取一个字符,然后将该字符存储在该变量中。get
函数不会跳过空白。当您使用get
作为循环条件时,如果它成功读取一个字符,并且程序应该继续读取,它将返回true
。如果没有更多的输入可用或者发生了某种输入错误,它将返回false
。
所有的数字字符都有连续的值,所以内部循环通过将一个字符与'0'
和'9'
的值进行比较来确定它是否是一个数字字符。如果它是一个数字,从它减去'0'
的值会得到一个 0 到 9 之间的整数。
最后一个循环读取字符,不做任何处理。循环在读取一个新的行字符时终止。换句话说,最后一个循环读取并忽略输入行的其余部分。
需要自己处理空白的程序(比如清单 17-1 )可以使用get
,或者你可以告诉输入流在读取一个数字或其他任何东西之前不要跳过空白。下一节将更详细地讨论字符 I/O。
字符输入输出
您刚刚了解到,get
函数读取单个字符,而不对空白进行特殊处理。你可以用普通的输入操作符做同样的事情,但是你必须使用std::noskipws
操作符。要恢复默认行为,使用std::skipws
操纵器(在<ios>
中声明)。
// Skip white space, then read two adjacent characters.
char left, right;
std::cin >> left >> std::noskipws >> right >> std::skipws;
关闭skipws
标志后,输入流不会跳过前导空白字符。例如,如果您试图读取一个整数,并且流位于空白位置,则读取将会失败。如果你试图读取一个字符串,该字符串将是空的,并且流的位置不会前进。所以你必须仔细考虑是否跳过空白。通常,只有在阅读单个字符时才这样做。
请记住,输入流使用了>>
操作符(探索 5 ),即使对于操纵器也是如此。使用>>
作为操纵器似乎打破了将数据转移到右边的记忆方法,但是它遵循了在输入流中总是使用>>
的惯例。如果你忘记了,编译器会提醒你。
写一个程序,一次读取输入流的一个字符,并把输入一字不差地回显到标准输出流。这不是一个如何复制流的演示,而是一个使用字符的例子。将你的程序与清单 17-2 进行比较。
import <iostream>;
int main()
{
std::cin >> std::noskipws;
char ch{};
while (std::cin >> ch)
std::cout << ch;
}
Listing 17-2.Echoing Input to Output, One Character at a Time
您也可以使用get
成员函数,在这种情况下,您不需要noskipws
操纵器。
让我们试试更有挑战性的东西。假设你要读一系列的点。这些点由一对用逗号分隔的 x 、 y 坐标定义。每个数字前后和逗号周围允许有空格。将这些点读入一个由 x 值组成的向量和一个由 y 值组成的向量。如果一个点没有正确的逗号分隔符,则终止输入循环。打印矢量内容,每行一个点。我知道这有点枯燥,但重点是试验字符输入。如果您愿意,可以对数据做一些特殊的处理。将您的结果与清单 17-3 进行比较。
import <algorithm>;
import <iostream>;
import <limits>;
import <vector>;
int main()
{
using intvec = std::vector<int>;
intvec xs{}, ys{}; // store the x's and y's
char sep{};
// Loop while the input stream has an integer (x), a character (sep),
// and another integer (y); then test that the separator is a comma.
for (int x{},y{}; std::cin >> x >> sep and sep == ',' and std::cin >> y;)
{
xs.emplace_back(x);
ys.emplace_back(y);
}
for (auto x{xs.begin()}, y{ys.begin()}; x != xs.end(); ++x, ++y)
std::cout << *x << ',' << *y << '\n';
}
Listing 17-3.Reading and Writing Points
第一个for
循环是关键。循环条件读取一个整数和一个字符,并在读取第二个整数之前测试确定该字符是否为逗号。如果输入无效或格式错误,或者如果循环到达文件结尾,则循环终止。一个更复杂的程序可以区分这两种情况,但这暂时是个枝节问题。
一个循环只能有一个定义,不能有两个。所以我不得不将sep
的定义移出循环头。将x
和y
放在头中可以避免与第二个for
循环中的变量发生冲突,这两个变量名称相同,但却是不同的变量。在第二个循环中,x
和y
变量是迭代器,不是整数。该循环同时迭代两个向量。基于范围的for
循环在这种情况下没有帮助,所以循环必须使用显式迭代器。
换行符和可移植性
你可能已经注意到清单 17-3 ,以及我到目前为止介绍的所有其他程序,在每行输出的末尾打印'\n'
。我们这样做没有考虑这到底意味着什么。不同的环境对行尾字符有不同的约定。UNIX 使用换行符('\x0a'
);macOS 使用回车('\x0d'
);DOS 和 Microsoft Windows 使用回车的组合,后跟换行符('\x0d\x0a'
);有些操作系统不使用行终止符,而是使用面向记录的文件,其中每一行都是一条单独的记录。
在所有这些情况下,C++ I/O 流会自动将本机行尾转换成单个的'\n'
字符。当您将'\n'
打印到输出流时,库会自动将其转换为本机行尾(或终止记录)。
换句话说,您可以编写使用'\n'
作为行尾的程序,而不用考虑本机 OS 约定。你的源代码可以移植到所有的 C++ 环境中。
字符转义
除了'\n'
,C++ 还提供了其他几个转义序列,比如用于水平制表符的'\t'
。表 17-1 列出了所有的字符转义。请记住,您可以在字符文本和字符串文本中使用这些转义。
表 17-1。
字符转义序列
|逃跑
|
意义
|
| --- | --- |
| \a
| 警报:响铃或以其他方式向用户发出信号 |
| \b
| 退格 |
| \f
| 换页 |
| \n
| 新行 |
| \r
| 回车 |
| \t
| 横表 |
| \v
| 垂直标签 |
| \\
| 文字\
|
| \'
| 文字'
|
| \"
| 文字"
|
| \
OOO | 八进制(基数 8)字符值 |
| \x
XX。。。 | 十六进制(16 进制)字符值 |
最后两项最有趣。一至三个八进制数字(0
至7
)的转义序列指定字符的值。该值表示哪个字符取决于实现。
理解了本文第一部分的所有注意事项后,有时您必须指定一个实际的字符值。最常见的是'\0'
,它是值为零的字符,也称为空字符,您可以利用它来初始化char
变量。它还有其他一些用途,尤其是在与 C 函数和 C 标准库接口时。
最后的转义序列(\x
)允许您指定十六进制的字符值。通常,您会使用两个十六进制数字,因为这是适合典型的 8 位char
的所有数字。(更长的\x
的目的是为了更广的人物,探索的主题 59 。)
下一个探索将通过研究 C++ 如何根据字母、数字、标点符号等对字符进行分类来继续您对字符的理解。
十八、字符类别
探索 17 介绍和讨论人物。本文继续讨论字符分类(例如,大写或小写、数字或字母),正如您将看到的那样,这比您想象的要复杂得多。
字符集
正如你在探索 17 中学到的,一个字符的数值,比如'A'
,取决于字符集。编译器必须决定在编译时和运行时使用哪个字符集。这通常基于最终用户在主机操作系统中选择的首选项。
用于编写 C++ 源代码的基本字符子集(如字母、数字和标点符号)很少出现字符集问题。您很可能会发现自己使用一个或多个具有一些共同特征的字符集。例如,所有 ISO 8859 字符集对罗马字母、数字和基本标点符号使用相同的数值。甚至大多数亚洲字符集都保留了这些基本字符的值。
因此,大多数程序员轻松地忽略了字符集的问题。我们使用字符文字,比如'%'
,并假设程序将按照我们期望的方式运行,在任何系统上,在世界的任何地方——我们通常是正确的。但并不总是如此。
假设基本字符总是以可移植的方式可用,我们可以修改单词计数程序,仅将字母视为组成单词的字符。程序将不再把right
和right?
当作两个不同的单词。string
类型提供了几个成员函数,可以帮助我们搜索字符串、提取子字符串等等。
例如,您可以构建一个字符串,该字符串只包含字母和您认为是单词一部分的任何其他字符(如'-'
)。从输入流中读取每个单词后,复制该单词,但只保留可接受字符串中的字符。使用find
成员函数尝试查找每个字符;如果找到,则find
返回字符从零开始的索引,如果没有找到,则返回std::string::npos
。
使用 find
功能,重写清单 15-3 以在将单词串插入映射之前对其进行清理。用各种输入样本测试程序。效果如何?将您的程序与清单 18-1 进行比较。
import <format>;
import <iostream>;
import <map>;
import <string>;
int main()
{
using count_map = std::map<std::string, int>;
using str_size = std::string::size_type;
count_map counts{};
std::string word{};
// Characters that are considered to be okay for use in words.
// Split a long string into parts, and the compiler joins the parts.
std::string okay{"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789-_"};
// Read words from the standard input and count the number of times
// each word occurs.
while (std::cin >> word)
{
// Make a copy of word, keeping only the characters that appear in okay.
std::string copy{};
for (char ch : word)
if (okay.find(ch) != std::string::npos)
copy.push_back(ch);
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
// Determine the longest word.
str_size longest{0};
for (auto pair : counts)
if (pair.first.size() > longest)
longest = pair.first.size();
// For each word/count pair...
constexpr int count_size{10}; // Number of places for printing the count
for (auto pair : counts)
// Print the word, count, newline. Keep the columns neatly aligned.
std::cout << std::format("{1:{0}}{3:{2}}\n",
longest, pair.first, count_size, pair.second);
}
Listing 18-1.Counting Words: Restricting Words to Letters and Letter-Like Characters
你们中的一些人可能写了一个和我非常相似的程序。你们中的其他人——尤其是那些生活在美国以外的人——可能编写了一个稍微不同的程序。也许您在可接受的字符串中包含了其他字符。
例如,如果您是法国人,并且使用 Microsoft Windows(和 Windows-1252 字符集),您可能已经定义了如下的okay
对象:
std::string okay{"ABCDEFGHIJKLMNOPQRSTUVWXYZÀÁÄÇÈÉÊËÎÏÔÙÛÜŒŸ"
"abcdefghijklmnopqrstuvwxyzàáäçèéêëîïöùûüœÿ"
"0123456789-_"};
但是,如果您试图在不同的环境中编译和运行这个程序,尤其是使用 ISO 8859-1 字符集(在 UNIX 系统中很流行)的环境,该怎么办呢?ISO 8859-1 和 Windows-1252 共享许多字符代码,但在一些重要方面有所不同。特别是,'Œ'
、'œ'
和'Ÿ'
这几个字在《ISO 8859-1》中不见了。因此,在编译时字符集使用 ISO 8859-1 的环境中,程序可能无法成功编译。
如果你想和一个德国用户分享程序呢?当然,用户会希望包含像'Ö'
、'ö'
和'ß'
这样的字母。希腊、俄罗斯和日本用户呢?
我们需要一个更好的解决方案。如果 C++ 提供一个简单的函数来通知我们一个字符是否是字母,而不强迫我们硬编码哪些字符是字母,这不是很好吗?幸运的是,确实如此。
字符类别
编写清单 18-1 中的程序的一个更简单的方法是调用isalnum
函数(在<locale>
中声明)。此函数指示运行时字符集中的字符是否为字母数字。使用isalnum
的好处是,你不必枚举所有可能的字母数字字符;你不必担心不同的字符集;而且你也不用担心不小心漏掉了批准字符串中的一个字符。
改写清单 18-1 将 isalnum
改为 find
。std::isalnum
的第一个参数是要测试的人物,第二个是std::locale{""}
。(先不要担心这意味着什么。请耐心等待:我很快就会谈到这一点。)
尝试用各种字母输入运行程序,包括重音字符。将结果与原始程序的结果进行比较。本书附带的文件包括一些使用各种字符集的示例。选择与您的日常字符集匹配的样本,再次运行程序,将输入重定向到该文件。
如果你需要这个程序的帮助,请参见清单 18-2 中我的程序版本。为了简洁起见,我删除了代码的简洁输出部分,恢复到简单的字符串和制表符。如果您愿意,可以随意恢复漂亮的输出。
import <iostream>;
import <locale>;
import <map>;
import <string>;
int main()
{
using count_map = std::map<std::string, int>;
count_map counts{};
std::string word{};
// Read words from the standard input and count the number of times
// each word occurs.
while (std::cin >> word)
{
// Make a copy of word, keeping only alphabetic characters.
std::string copy{};
for (char ch : word)
if (std::isalnum(ch, std::locale{""}))
copy.push_back(ch);
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
// For each word/count pair, print the word & count on one line.
for (auto pair : counts)
std::cout << pair.first << '\t' << pair.second << '\n';
}
Listing 18-2.Testing a Character by Calling std::isalnum
现在把你的注意力转向std::locale{""}
论点。语言环境将std::isalnum
指向它应该用来测试字符的字符集。正如你在探索 17 中看到的,字符集根据数字值决定角色的身份。用户可以在程序运行时更改字符集,因此程序必须跟踪用户的实际字符集,而不能依赖于编译程序时活动的字符集。
下载本书附带的文件,找到名称以sample
开头的文本文件。找到与您每天使用的字符集最匹配的文件,并选择该文件作为程序的重定向输入。在输出中寻找特殊字符的外观。
将清单 18-2 中的黑体字行locale{""}
改为locale{}
。现在用相同的输入编译并运行程序。你看出区别了吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _如果是,有什么区别?
在不了解您的环境的情况下,我无法告诉您应该期待什么。如果您使用的是 Unicode 字符集,您将看不到任何区别。该程序不会将任何特殊字符视为字母,即使你可以清楚地看到它们是字母。这是由于 Unicode 的实现方式,Exploration 55 将深入讨论这个话题。
其他用户会注意到只有一两个字符串输出。使用 ISO 8859-1 的西欧人可能会注意到ÁÇÐÈ
被认为是一个单词。ISO 8859-7 的希腊语用户会将αβγδε视为一个单词。
知道如何动态更改字符集的高级用户可以尝试几种不同的方法。您必须更改程序在运行时使用的字符集以及控制台用来显示文本的字符集。
最值得注意的是,程序认为是字母的字符在不同的字符集之间有所不同。但毕竟那是不同字符集的想法。关于哪些字符是哪些字符集的字母的知识体现在语言环境中。
现场
在 C++ 中, locale 是关于文化、地区和语言的信息集合。区域设置包括以下信息
-
格式化数字、货币、日期和时间
-
分类字符(字母、数字、标点符号等。)
-
将字符从大写转换成小写,反之亦然
-
对文本进行排序(例如,
'A'
是小于、等于还是大于'Å'
?) -
消息目录(用于翻译程序使用的字符串)
每个 C++ 程序都以一个最小的标准语言环境开始,这个语言环境被称为经典或"C"
语言环境。std::locale::classic()
函数返回传统的语言环境。未命名的语言环境std::locale{""}
,是 C++ 从主机操作系统获得的用户首选项的集合。带有空字符串参数的地区通常被称为本地地区。
经典语言环境的优点是它的行为是已知的和固定的。如果你的程序必须以固定的格式读取数据,你不希望用户的偏好妨碍你。相比之下,原生格式的优势在于用户选择这些偏好是有原因的,并且希望看到程序输出遵循该格式。总是指定日期为日/月/年的用户不希望程序打印月/日/年,因为这是程序员本国的惯例。
因此,经典格式通常用于读写数据文件,而本机格式最适合用于解释来自用户的输入并直接向用户呈现输出。
每个 I/O 流都有自己的locale
对象。为了影响流的locale
,调用它的imbue
函数,传递locale
对象作为唯一的参数。
Note
你没看错:imbue
,而不是setlocale
或setloc
——假设getloc
函数返回流的当前区域设置——或者任何容易记住的东西。另一方面,imbue
对于成员函数来说是一个不常见的名字;你可能仅仅因为这个原因而记得它。
换句话说,当 C++ 启动时,它用经典语言环境初始化每个流,如下所示:
std::cin.imbue(std::locale::classic());
std::cout.imbue(std::locale::classic());
假设您想要更改输出流以采用用户的本地语言环境。在程序开始时使用下面的语句来实现这一点:
std::cout.imbue(std::locale{""});
例如,假设您必须编写一个程序,从标准输入中读取一系列数字并计算总和。这些数字是来自科学仪器的原始数据,所以它们被写成数字串。因此,您应该继续使用经典的语言环境来读取输入流。输出是为了用户的利益,所以输出应该使用本地语言环境。
写程序,用非常大的数字试,输出会大于 1000。程序的输出是什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
参见清单 18-3 了解我解决这个问题的方法。
import <iostream>;
import <locale>;
int main()
{
std::cout.imbue(std::locale{""});
int sum{0};
int x{};
while (std::cin >> x)
sum = sum + x;
std::cout << "sum = " << sum << '\n';
}
Listing 18-3.Using the Native Locale for Output
当我在我的默认地区(美国)运行清单 18-3 中的程序时,我得到以下结果:
sum = 1,234,567
注意分隔千位的逗号。在一些欧洲国家,您可能会看到以下内容:
sum = 1.234.567
您应该获得符合本地习惯的结果,或者至少遵循您在主机操作系统中设置的首选项。
当您使用本地语言环境时,我建议定义一个类型为std::
locale
的变量来存储它。您可以将此变量传递给isalnum
、imbue
或其他函数。通过创建这个变量并分发它的副本,你的程序只需要向操作系统查询一次你的偏好,而不是每次你需要locale
的时候。因此,主循环最终看起来类似于清单 18-4 。
import <iostream>;
import <locale>;
import <map>;
import <string>;
int main()
{
using count_map = std::map<std::string, int>;
std::locale native{""}; // Get the native locale.
std::cin.imbue(native); // Interpret the input and output according
std::cout.imbue(native); // to the native locale.
count_map counts{};
std::string word{};
// Read words from the standard input and count the number of times
// each word occurs.
while (std::cin >> word)
{
// Make a copy of word, keeping only alphabetic characters.
std::string copy{};
for (char ch : word)
if (std::isalnum(ch, native))
copy.push_back(ch);
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
// For each word/count pair, print the word & count on one line.
for (auto pair : counts)
std::cout << pair.first << '\t' << pair.second << '\n';
}
Listing 18-4.Creating and Sharing a Single Locale Object
改进单词计数程序的下一步是忽略大小写差异,这样程序就不会将单词The
视为与the
不同。事实证明,这个问题比它第一次出现时要复杂得多,所以它值得一个完整的探索。
十九、大小写
继续我们在探索 18 中停止的地方,改进字数统计程序的下一步是更新它,以便它在计数时忽略大小写差异。例如,程序应该像计算the
一样计算The
。这是计算机编程中的一个经典问题。C++ 提供了一些基本的帮助,但是缺少一些重要的基础部分。这篇文章对这个看似棘手的问题进行了更深入的研究。
简单的案例
西欧语言长期以来一直使用大写字母和小写字母。更熟悉的术语——大写字母和小写字母——来自早期的排版技术,那时用于大写字母的铅字嵌条存放在大架子的上端,架子上装有用于制作印刷版的所有嵌条。在它们下面是箱子,或者盒子,用来储存微小的字母碎片。
在<locale>
头中,C++ 声明了isupper
和islower
函数。它们将一个字符作为第一个参数,将一个locale
作为第二个参数。如果字符是大写字母(或小写字母),返回值是一个bool
: true
,如果字符是小写字母(或大写字母)或不是字母,返回值是false
。
std::isupper('A', std::locale{"en_US.latin1"}) == true
std::islower('A', std::locale{"en_US.latin1"}) == false
std::isupper('Æ', std::locale{"en_US.latin1"}) == true
std::islower('Æ', std::locale{"en_US.latin1"}) == false
std::islower('½', std::locale{"en_US.latin1"}) == false
std::isupper('½', std::locale{"en_US.latin1"}) == false
<locale>
头还声明了两个转换大小写的函数:toupper
将小写转换成大写。如果它的字符参数不是小写字母,toupper
按原样返回字符。类似地,如果有问题的字符是大写字母,tolower
转换为小写。就像类别测试函数一样,第二个参数是一个locale
对象。
现在你可以修改 字数统计程序 将大写字母折叠成小写字母,并以小写字母统计所有单词。从 Exploration 18 修改你的程序,或者从清单 18-4 开始。如果你有困难,看一下清单 19-1 。
import <iostream>;
import <locale>;
import <map>;
import <string>;
int main()
{
using count_map = std::map<std::string, int>;
std::locale native{""}; // get the native locale
std::cin.imbue(native); // interpret the input and output according to
std::cout.imbue(native); // the native locale
count_map counts{};
std::string word{};
// Read words from the standard input and count the number of times
// each word occurs.
while (std::cin >> word)
{
// Make a copy of word, keeping only alphabetic characters.
std::string copy{};
for (char ch : word)
if (std::isalnum(ch, native))
copy.push_back(tolower(ch, native));
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
// For each word/count pair, print the word & count on one line.
for (auto pair : counts)
std::cout << pair.first << '\t' << pair.second << '\n';
}
Listing 19-1.Folding Uppercase to Lowercase Prior to Counting Words
那很容易。那有什么问题呢?
更棘手的案子
你们中的一些人——尤其是德国读者——已经知道这个问题。一些语言的字母组合不容易在大写和小写之间进行映射,或者一个字符映射到两个字符。德语 Eszett ,ß
,是小写字母;转换成大写,就得到两个字符:SS
。因此,如果您的输入文件包含“ESSEN
”和“”),您希望它们映射到同一个单词,因此它们被计数在一起,但这在 C++ 中是不可行的。该程序目前的工作方式是将“ESSEN
”映射到“essen
”,并将其视为与“eßen
”不同的单词。一个简单的解决方案是将“essen
”映射到“eßen
”,但并不是所有的ss
用法都等同于ß
。
希腊读者熟悉另一种问题。希腊文的小写σ有两种形式:在单词末尾使用ς,在其他地方使用σ。我们的简单程序将σ(大写 sigma)映射到σ,因此一些全大写的单词不会转换为与其小写版本匹配的形式。
有时,在转换过程中会丢失重音。将é
映射到大写通常会产生É
,但也可能会产生E
。将大写字母映射成小写字母的问题较少,因为É
映射到é
,但是如果那个E
(映射到e)
实际上表示É
,并且您希望它映射到é
呢?程序无法知道作者的意图,所以它所能做的就是映射它收到的信件。
有些字符集比其他字符集更有问题。例如,ISO 8859-1 有一个小写字母ÿ
,但没有大写字母(ϋ).另一方面,Windows-1252 扩展了 ISO 8859-1,其中一个新的代码点是ϋ.
Tip
码位是“代表字符的数值”的一种奇特说法虽然大多数程序员在日常生活中不使用代码点,但是那些与字符集问题密切相关的程序员一直在使用它,所以你也可以习惯它。主流程序员应该更习惯使用这个短语。
换句话说,只使用标准 C++ 库是不可能正确转换大小写的。
如果你知道你的字母表是 C++ 能正确处理的,那么继续使用toupper
和tolower
。例如,如果您正在编写一个命令行解释程序,在其中您可以完全控制命令,并且您决定用户应该能够在任何情况下输入命令,那么只需确保命令从一种情况正确映射到另一种情况。这很容易做到,因为所有字符集都可以毫无问题地映射罗马字母表的 26 个字母。
另一方面,如果您的程序接受来自用户的输入,并且您想要将该输入映射为大写或小写,那么您不能也不应该使用标准 C++。例如,如果您正在编写一个字处理器,并且您决定需要实现一些大小写折叠功能,那么您必须编写或获取一个标准之外的库来正确地实现大小写折叠逻辑。最有可能的是,你需要一个字符和字符串函数库来实现你的文字处理器。案例折叠只是这个假想库中的一小部分。(参见这本书的网站,获得一些可以帮助你的非假设库的链接。)
我们的简单程序呢?当你只想计算几个单词的时候,完全、完整、正确地处理大小写并不总是可行的。办案代码会让字数统计代码相形见绌。
在这种情况下(双关语),你必须接受你的程序有时会产生错误结果的事实。我们可怜的小程序永远也不会认出“ESSEN
”和“eßen
”是同一个单词,但大小写不同。您可以通过先映射到大写字母,然后再映射到小写字母来解决一些多重映射(例如希腊 sigma)。另一方面,这可能会引入一些重音字符的问题。我还没有触及“naïve
”和“naive
”是不是同一个词的问题。在某些语言环境中,音调符号非常重要,这将导致“naïve
”和“naive
”被解释为两个不同的单词。在其他地区,它们是同一个单词,应该一起计算。
在某些字符集中,重音字符可以由单独的非重音字符后跟所需的重音字符组成。比如,也许你可以写“na``¨ve
”,和“naïve
”一样。
我希望现在你已经完全害怕操纵案件和角色了。太多天真的程序员陷入了这个网络,或者更糟糕的是,简单地写出糟糕的代码。我很想等到本书很晚的时候再告诉你,但是我知道很多读者想通过忽略 case 来改进字数统计程序,所以我决定早点解决这个问题。
现在你更清楚了。
这并不意味着你不能继续从事字数统计项目。下一个探索将回到现实可行的领域,因为我最后将向您展示如何编写自己的函数。
二十、编写函数
最后,是时候开始编写自己的函数了。在这次探索中,您将从改进您在过去五次探索中制作的字数统计程序开始,编写函数来实现程序功能的不同方面。
函数
从你写的第一个程序开始,你就一直在使用函数。事实上,您也一直在编写函数。你看,main()
是一个函数,你应该像看待任何其他函数一样看待它(嗯,某种程度上,main()
实际上与普通函数有一些关键的不同,但它们还不需要你来关心)。
一个函数有一个返回类型,一个名字和圆括号中的参数。接下来是一个复合语句,也就是函数体。如果函数没有参数,括号是空的。每个参数就像一个变量声明:类型和名称。参数用逗号分隔,因此不能在单个类型名后声明两个参数。相反,您必须为每个参数显式指定类型。
一个函数通常至少有一个return
语句,这会导致函数停止执行,并将控制权返回给调用者。return
语句的结构以return
关键字开头,后跟一个表达式,以分号结尾,如以下示例所示:
return 42;
您可以在任何需要语句的地方使用return
语句,并且可以根据需要使用任意多的return
语句。唯一的要求是通过函数的每个执行路径必须有一个return
语句。如果你忘记了,许多编译器会警告你。
有些语言区分返回值的函数和不返回值的过程或子例程。C++ 把它们都叫做函数。如果函数没有返回值,将返回类型声明为void
。在void
函数中省略return
语句中的值:
return;
如果函数返回void
,也可以完全省略return
语句。在这种情况下,当执行到达函数体的末尾时,控制返回到调用方。清单 20-1 给出了一些函数示例。
import <iostream>;
import <string>;
/** Ignore the rest of the input line. */
void ignore_line()
{
char c{};
while (std::cin.get(c) and c != '\n')
/*empty*/;
}
/** Prompt the user, then read a number, and ignore the rest of the line.
* @param prompt the prompt string
* @return the input number or 0 for end-of-file
*/
int prompted_read(std::string prompt)
{
std::cout << prompt;
int x{0};
std::cin >> x;
ignore_line();
return x;
}
/** Print the statistics.
* @param count the number of values
* @param sum the sum of the values
*/
void print_result(int count, int sum)
{
if (count == 0)
{
std::cout << "no data\n";
return;
}
std::cout << "\ncount = " << count;
std::cout << "\nsum = " << sum;
std::cout << "\nmean = " << sum/count << '\n';
}
/** Main program.
* Read integers from the standard input and print statistics about them.
*/
int main()
{
int sum{0}, count{0};
while (std::cin)
{
if (int x{prompted_read("Value: ")}; std::cin)
{
sum = sum + x;
++count;
}
}
print_result(count, sum);
}
Listing 20-1.Examples of Functions
清单 20-1 是做什么的?
ignore_line
函数从std::cin
开始读取并丢弃字符,直到到达行尾或文件尾。它不接受任何参数,也不向调用者返回任何值。
prompted_read
函数向std::cout
打印一个提示,然后从std::cin
读取一个数字。然后,它会丢弃输入行的其余部分。因为x
被初始化为0
,如果读取失败,函数返回0
。调用者不能区分输入流中的失败和真正的0
,所以main()
函数测试std::cin
以知道何时终止循环。(数值0
不重要;随意将x
初始化为任何值。)该函数的唯一参数是提示字符串。返回类型为int
,返回值为从std::cin
读取的数字。
print_result
函数有两个参数,都是类型int
。它不返回任何内容;它只是打印结果。请注意,如果输入不包含任何数据,它会提前返回。
最后,main()
函数把这些都放在一起,反复调用prompted_read
,积累数据。一旦输入结束,main()
打印结果,在这个例子中,是从标准输入中读取的整数的和、计数和平均值。
函数调用
在函数调用中,在调用函数之前,所有参数都被求值。每个实参被复制到函数中相应的形参,然后函数体开始运行。当函数执行一个return
语句时,语句中的值被复制回调用者,然后调用者可以在表达式中使用该值,将其赋给一个变量,等等。
在本书中,我尽量小心术语:参数是函数调用中的表达式,参数是函数头中的变量。我也见过短语实参用于实参形参用于形参。我发现这些令人困惑,所以我建议您坚持使用术语参数和参数。
声明和定义
我以自底向上的方式编写函数,因为 C++ 在编译对函数的任何调用之前必须了解这个函数。在简单的程序中实现这一点的最简单的方法是在调用函数之前编写每个函数——也就是说,在源文件中,在调用函数之前编写函数。
如果你愿意,你可以用自顶向下的方式编码,先写main()
,然后是它调用的函数。在你调用它们之前,编译器仍然需要知道这些函数,但是你不需要提供完整的函数。相反,您只需提供编译器要求的内容:返回类型、名称和括号中以逗号分隔的参数列表。清单 20-2 展示了源代码的这种新安排。
import <iostream>;
import <string>;
void ignore_line();
int prompted_read(std::string prompt);
void print_result(int count, int sum);
/** Main program.
* Read integers from the standard input and print statistics about them.
*/
int main()
{
int sum{0}, count{0};
while (std::cin)
{
if (int x{ prompted_read("Value: ") }; std::cin)
{
sum = sum + x;
++count;
}
}
print_result(count, sum);
}
/** Prompt the user, then read a number, and ignore the rest of the line.
* @param prompt the prompt string
* @return the input number or -1 for end-of-file
*/
int prompted_read(std::string prompt)
{
std::cout << prompt;
int x{-1};
std::cin >> x;
ignore_line();
return x;
}
/** Ignore the rest of the input line. */
void ignore_line()
{
char c{};
while (std::cin.get(c) and c != '\n')
/*empty*/;
}
/** Print the statistics.
* @param count the number of values
* @param sum the sum of the values
*/
void print_result(int count, int sum)
{
if (count == 0)
{
std::cout << "no data\n";
return;
}
std::cout << "\ncount = " << count;
std::cout << "\nsum = " << sum;
std::cout << "\nmean = " << sum/count << '\n';
}
Listing 20-2.Separating Function Declarations from Definitions
完整地编写函数被认为是提供了一个定义。单独编写函数头——即返回类型、名称和参数,后跟一个分号——被称为声明。一般来说,声明告诉编译器如何使用名字:名字是程序的哪一部分(类型定义,变量,函数,等等)。)、名称的类型以及编译器为确保您的程序正确使用该名称而需要的任何其他信息(如函数参数)。定义提供了名称的主体或实现。函数的声明必须与其定义相匹配:返回类型、名称和参数类型必须相同。但是,参数名称可以不同。
定义也是声明,因为实体的完整定义也告诉 C++ 如何使用该实体。
在 C++ 中,声明和定义的区别是至关重要的。到目前为止,我们的简单程序还不需要面对这种差异,但这种情况很快就会改变。记住:声明向编译器描述了一个名字,而定义提供了编译器为你定义的实体所需要的所有细节。
为了使用一个变量,比如一个函数参数,编译器只需要声明它的名字和类型。然而,对于局部变量,编译器需要一个定义,以便知道留出内存来存储变量。该定义还可以提供变量的初始值。即使没有显式的初始值,编译器也可以生成代码来初始化变量,例如确保将string
或vector
正确初始化为空。
数单词——再次
轮到你了。重写字数统计程序(最后一次出现在《探索》 19 ),这次使用了函数。例如,您可以通过将漂亮打印实用程序封装在一个函数中来恢复它。这里有一个提示:你可能想在多个函数中使用typedef
名称。如果是这样,在第一个函数之前,在import
声明之后声明它们。
测试程序以确保您的更改没有改变它的行为。
将您的程序与清单 20-3 进行比较。
import <format>;
import <iostream>;
import <locale>;
import <map>;
import <string>;
using count_map = std::map<std::string, int>; ///< Map words to counts
using count_pair = count_map::value_type; ///< pair of a word and a count
using str_size = std::string::size_type; ///< String size type
/** Initialize the I/O streams by imbuing them with
* the given locale. Use this function to imbue the streams
* with the native locale. C++ initially imbues streams with
* the classic locale.
* @param locale the native locale
*/
void initialize_streams(std::locale locale)
{
std::cin.imbue(locale);
std::cout.imbue(locale);
}
/** Find the longest key in a map.
* @param map the map to search
* @returns the size of the longest key in @p map
*/
str_size get_longest_key(count_map map)
{
str_size result{0};
for (auto pair : map)
if (pair.first.size() > result)
result = pair.first.size();
return result;
}
/** Print the word, count, newline
. Keep the columns neatly aligned.
* Rather than the tedious operation of measuring the magnitude of all
* the counts and then determining the necessary number of columns, just
* use a sufficiently large value for the counts column.
* @param iter an iterator that points to the word/count pair
* @param longest the size of the longest key; pad all keys to this size
*/
void print_pair(count_pair pair, str_size longest)
{
constexpr int count_size{10}; // Number of places for printing the count
std::cout << std::format("{1:{0}}{3:{2}}\n",
longest, pair.first, count_size, pair.second);
}
/** Print the results in neat columns.
* @param counts the map of all the counts
*/
void print_counts(count_map counts)
{
str_size longest{get_longest_key(counts)};
// For each word/count pair...
for (count_pair pair : counts)
print_pair(pair, longest);
}
/** Sanitize a string by keeping only alphabetic characters.
* @param str the original string
* @param loc the locale used to test the characters
* @return a sanitized copy of the string
*/
std::string sanitize(std::string str, std::locale loc)
{
std::string result{};
for (char ch : str)
if (std::isalnum(ch, loc))
result.push_back(std::tolower(ch, loc));
return result;
}
/** Main program to count unique words in the standard input. */
int main()
{
std::locale native{""}; // get the native locale
initialize_streams(native);
count_map counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
{
std::string copy{ sanitize(word, native) };
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
print_counts(counts);
}
Listing 20-3.Using Functions to Clarify the Word-Counting Program
通过使用函数,您可以将程序分成更小的部分来读、写和维护,将每个部分作为一个独立的实体来处理。你可以一次阅读、理解并消化一个函数,然后继续下一个函数,而不是被一个冗长的main()
淹没。编译器通过确保函数调用与函数声明相匹配、函数定义和声明一致、没有键入错误的名称以及函数返回类型与调用函数的上下文相匹配来保持诚实。
main()
函数
现在你对函数有了更多的了解,可以回答你可能已经问过自己的问题:main()
函数** 有什么特别之处?**
*** _____________________________________________________________
main()
与普通函数的一个不同之处显而易见。本书中所有的main()
函数都缺少一个return
语句。一个返回int
的普通函数必须至少有一个return
语句,但是main()
是特殊的。如果不提供自己的return
语句,编译器会在main()
的末尾插入一个return 0;
语句。如果控制到达函数体的末尾,效果与return 0;
相同,它向操作系统返回一个成功状态。如果您想向操作系统发出错误信号,您可以从main()
返回一个非零值。操作系统如何解释该值取决于实现。返回的唯一可移植值是0
、EXIT_SUCCESS
和EXIT_FAILURE
。EXIT_SUCCESS
与0
的意思相同——即成功,但其实际值可能与0
不同。名称在<cstdlib>
中声明。
下一篇文章将通过仔细观察函数调用中的参数来继续研究函数。**
二十一、函数参数
本探索继续研究探索 20 中介绍的函数,重点是参数传递。仔细看看。记住参数是在函数调用中传递给函数的表达式。参数是你在函数声明中声明的变量。这个探索引入了函数参数的主题,这是 C++ 中令人惊讶的复杂和微妙的一个领域。
参数传递
通读清单 21-1 ,然后回答下面的问题。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
void modify(int x)
{
x = 10;
}
int triple(int x)
{
return 3 * x;
}
void print_vector(std::vector<int> v)
{
std::cout << "{ ";
std::copy(v.begin(), v.end(), std::ostream_iterator<int>(std::cout, " "));
std::cout << "}\n";
}
void add(std::vector<int> v, int a)
{
for (auto iter(v.begin()), end(v.end()); iter != end; ++iter)
*iter = *iter + a;
}
int main()
{
int a{42};
modify(a);
std::cout << "a=" << a << '\n';
int b{triple(14)};
std::cout << "b=" << b << '\n';
std::vector<int> data{ 10, 20, 30, 40 };
print_vector(data);
add(data, 42);
print_vector(data);
}
Listing 21-1.Function Arguments and Parameters
预测程序将打印什么 。
现在编译并运行程序。它实际上打印的是什么?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _解释程序为什么会这样。
当我运行该程序时,我得到以下结果:
a=42
b=42
{ 10 20 30 40 }
{ 10 20 30 40 }
扩展这些结果,您可能已经注意到modify
函数实际上并没有修改main()
中的变量a
,并且add
函数也没有修改data
。您的编译器甚至可能会发出这方面的警告。
如您所见,C++ 通过值传递参数——也就是说,它将参数值复制到参数中。函数可以对参数做任何它想做的事情,但是当函数返回时,参数就消失了,调用者永远看不到函数所做的任何改变。
如果你想返回一个值给调用者,使用一个return
语句,就像在triple
函数中所做的那样。
重写 add
函数,使其将修改后的向量返回给调用者。
将您的解决方案与以下代码块进行比较:
std::vector<int> add(std::vector<int> v, int a)
{
std::vector<int> result{};
for (auto i : v)
result.emplace_back(i + a);
return result;
}
要调用新的add
,必须将函数的结果赋给一个变量。
data = add(data, 42);
这个新版本的 add
有什么问题?
考虑当你用一个非常大的向量调用add
时会发生什么。该函数对其参数进行了全新的复制,消耗了两倍于实际所需的内存。
按引用传递
C++ 让你通过引用传递大型对象,而不是通过值传递大型对象(比如向量)。在函数参数声明中的类型名称后添加一个&符号(&
)。更改清单 21-1 通过引用传递矢量参数。也改变modify
功能,但保留其他int
参数。您预测会有什么样的输出?
现在编译并运行程序。它实际上打印的是什么?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _解释程序为什么会这样。
清单 21-2 显示了程序的新版本。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
void modify(int& x)
{
x = 10;
}
int triple(int x)
{
return 3 * x;
}
void print_vector(std::vector<int>& v)
{
std::cout << "{ ";
std::ranges::copy(v, std::ostream_iterator<int>(std::cout, " "));
std::cout << "}\n";
}
void add(std::vector<int>& v, int a)
{
for (auto iter{v.begin()}, end{v.end()}; iter != end; ++iter)
*iter = *iter + a;
}
int main()
{
int a{42};
modify(a);
std::cout << "a=" << a << '\n';
int b{triple(14)};
std::cout << "b=" << b << '\n';
std::vector<int> data{ 10, 20, 30, 40 };
print_vector(data);
add(data, 42);
print_vector(data);
}
Listing 21-2.Pass Parameters by Reference
当我运行该程序时,我得到以下结果:
a=10
b=42
{ 10 20 30 40 }
{ 52 62 72 82 }
这一次,程序修改了modify
中的x
参数,并更新了add
中的矢量内容。
改变其余参数,使用按引用传递。你预计会发生什么?
试试看。实际上会发生什么?
当triple
的参数是引用时,编译器不允许你调用triple(14)
。考虑一下如果triple
试图修改它的参数会发生什么。你不能给一个数字赋值,只能给一个变量赋值。变量和文字属于不同类别的表达式。一般来说,变量是一个左值,引用也是。一个文字被称为右值,由操作符和函数调用构建的表达式通常会产生右值。当参数是引用时,函数调用中的参数必须是左值。如果参数是按值调用,则可以传递一个右值。
你能给一个按值调用的参数传递一个左值吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
你已经看到了很多传递左值的例子。C++ 会在需要时自动将任何左值转换成右值。你能把右值转换成左值吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
如果你不确定,试着用更具体的术语来思考这个问题:你能把一个整数文字转换成一个变量吗?这意味着不能将右值转换为左值。除了,有时你可以,下一节将解释。
const
参考文献
在修改后的程序中,print_vector
函数通过引用获取其参数,但它不修改参数。这为编程错误打开了一个窗口:您可能会意外地编写代码来修改 vector。为了防止这种错误,您可以恢复到按值调用,但是如果参数很大,您仍然会有内存问题。理想情况下,您将能够通过引用传递参数,但仍然防止函数修改其参数。好吧,事实证明,这样的方法确实存在。还记得const
吗?C++ 也允许你声明一个函数参数const
。
void print_vector(std::vector<int> const& v)
{
std::cout << "{ ";
std::ranges::copy(v, std::ostream_iterator<int>(std::cout, " "));
std::cout << "}\n";
}
从参数名开始,从右到左阅读参数声明。参数名为v
;它是一个参考;引用了一个const
对象;而对象类型是std::vector<int>
。有时候,C++ 可能很难读懂,尤其是对于一个语言新手来说,但是通过练习,你很快就会轻松地读懂这样的声明。
CONST WARS
许多 C++ 程序员将const
关键字放在类型前面,如下所示:
void print_vector(const std::vector<int>& v)
对于简单的定义,const
的位置并不重要。例如,要定义一个命名常量,可以使用
const int max_width{80}; // maximum line width before wrapping
那和的区别
int const max_width{80}; // maximum line width before wrapping
很小。但是对于更复杂的声明,比如对print_vector
的参数,不同的风格更有意义。我发现我的技术更容易阅读和理解。我的经验是让const
关键字尽可能接近它所修改的内容。
越来越多的 C++ 程序员开始采用const
靠近名字的风格,而不是const
在前面。同样,这也是一个让你走在最新 C++ 编程潮流前沿的机会。但是你必须习惯阅读前面有const
的代码,因为你会看到很多。
所以,v
是对一个const
向量的引用。因为向量是const
,编译器阻止print_vector
函数修改它(添加元素、擦除元素、改变元素等等)。去试试吧。看看如果您输入以下任何一行会发生什么:
v.emplace_back(10); // add an element
v.pop_back(); // remove the last element
v.front() = 42; // modify an element
编译器会阻止您修改const
参数。
标准的做法是使用引用来传递任何大型数据结构,比如vector
、map
或string
。如果该函数无意进行更改,则将引用声明为一个const
。对于小对象,如int
,使用传递值。
如果一个参数是对const
的引用,你可以传递一个右值作为参数。这是一个例外,允许您将右值转换为左值。要查看这是如何工作的,将triple
的参数改为对const
的引用。
int triple(int const& x)
说服自己可以传递一个右值(比如 14)给triple
。因此,更精确的规则是可以将右值转换成const
左值,但不能转换成非const
左值。
常量迭代器
使用const
参数时你必须知道的另一个技巧是:如果你需要一个迭代器,使用const_iterator
而不是iterator
。一个iterator
类型的const
变量不是很有用,因为你不能修改它的值,所以迭代器不能前进。您仍然可以通过向解引用迭代器赋值来修改元素(例如,*iter
)。相反,一个const_iterator
可以被修改和升级,但是当你解引用迭代器时,得到的对象是const
。因此,您可以读取值,但不能修改它们。这意味着您可以安全地使用一个const_iterator
来迭代一个const
容器。
void print_vector(std::vector<int> const& v)
{
std::cout << "{ ";
std::string separator{};
for (std::vector<int>::const_iterator i{v.begin()}, end{v.end()}; i != end; ++i)
{
std::cout << separator << *i;
separator = ", ";
}
std::cout << "}\n";
}
您可以使用基于范围的 for 循环打印相同的结果,但是我想展示一下const_iterator
的用法。
字符串参数
字符串提供了一个独特的机会。将const
字符串传递给函数是常见的做法,但是当涉及到字符串时,C++ 和它的祖先 C 之间有一个不幸的不匹配。
c 缺少内置的字符串类型。它的标准库中也没有任何字符串类型。带引号的字符串文字相当于一个 char 数组,编译器会向该数组追加一个 NUL 字符('\0'
)来表示字符串的结尾。当一个程序从一个带引号的字符串中构造一个 C++ std::string
时,std::string
对象必须复制字符串的内容。这意味着,如果一个函数声明了一个类型为std::string const&
的参数以避免复制实参,并且调用者传递了一个字符串文字,那么这个文字无论如何都会被复制。
这个问题的解决方案被添加到 C++ 17 的std::string_view
类中。A string_view
不复制任何东西。相反,它是引用std::string
或引用字符串文字的一种小而快速的方式。所以可以使用std::string_view
作为函数参数类型如下:
int prompted_read(std::string_view prompt)
{
std::cout << prompt;
int x{0};
std::cin >> x;
ignore_line();
return x;
}
在 Exploration 20 中,调用prompted_read("Value: ")
需要构造一个std::string
对象,将字符串文字复制到该对象中,然后将该对象传递给函数。但是编译器可以在不复制任何数据的情况下构建并传递一个string_view
。string_view
对象是现有字符串的轻量级只读视图。你通常可以像对待 const std::string
一样对待一只string_view
。每当你想传递一个只读字符串给一个函数时,使用string_view
;函数参数可以是带引号的字符串、另一个string_view
或std::string
对象。使用string_view
的唯一警告是,标准库还没有流行到string_view
上,并且该库的许多部分只接受string
而不接受string_view
。在本书中,当你看到字符串作为std::string const&
而不是std::string_view
传递时,是因为函数必须调用一些不处理string_view
参数的标准库函数。
多输出参数
你已经看到了如何从一个函数返回值。您已经看到了函数如何通过将参数声明为引用来修改参数。您可以使用引用参数从函数中“返回”多个值。例如,您可能希望编写一个从标准输入中读取一对数字的函数,如下所示:
void read_numbers(int& x, int& y)
{
std::cin >> x >> y;
}
现在您已经知道如何将字符串、向量等传递给函数,您可以开始进一步改进字数统计程序,在下一篇文章中将会看到。
二十二、使用范围
正如您所看到的,对一系列数据执行某种操作是很常见的事情。到目前为止,我们的程序很简单,几乎没有触及 C++ 提供的可能性。主要的限制是,许多更有趣的算法要求你提供一个函数来做任何有用的事情。这个探索着眼于这些更先进的算法。此外,我们将重温一些你已经知道的算法,并展示如何以新的奇妙的方式使用它们。
转换数据
您读过和写过的几个程序都有一个共同的主题:复制一个数据序列,比如一个vector
或string
,并对每个元素应用某种转换(转换成小写,将数组中的值加倍,等等)。标准算法transform
非常适合对一个范围内的元素应用任意复杂的变换。
例如,回想一下清单 10-5,它将一个数组中的所有值加倍。清单 22-1 展示了一种编写相同程序的新方法,但是使用了transform
和范围适配器。
import <iostream>;
import <iterator>;
import <ranges>;
int times_two(int i)
{
return i * 2;
}
int plus_three(int i)
{
return i + 3;
}
int main()
{
auto data{ std::ranges::istream_view<int>(std::cin)
| std::ranges::views::transform(times_two)
| std::ranges::views::transform(plus_three)
};
for (auto element : data)
std::cout << element << '\n';
}
Listing 22-1.Calling transform to Apply a Function to Each Element of a Range
哇哦!这看起来肯定不同于以前的程序。在开始之前,如果用下面的输入编译并运行程序,你认为会发生什么?
1 2
3
我得到的输出是:
5
7
9
您已经看到了istream_view
,所以您知道它从输入源读取值,比如标准输入。在这种情况下,它读取的值是整数。它产生一系列值,称为范围。
除了使用一个 ranged for
循环之外,一个 range 还可以为一个管道提供数据,如 pipe ( |
)操作符所示。范围也可以将管道作为输入。在本例中,transform
是一个范围适配器。它为范围内的每一项调用用户提供的函数。
管道以一个范围开始,并包含任意数量的后续范围适配器或视图。范围适配器调整范围算法以在管道中使用,标准库在std::ranges::views
名称空间中提供了几个,您可以通过简单地使用std::views
来自由缩短名称空间。
transform
函数有几种风格。这个函数有两个参数:要转换的数据和函数名。借助于<ranges>
头文件的帮助,大多数算法都在<algorithm>
头文件中声明。你通常都需要。尽管data
变量似乎包含了整个范围,就像它在清单 10-5 中一样,范围适配器一次处理一个元素的数据,而不是存储任何数据。所以程序的第一部分是建立数据管道。第二部分是for
循环,评估管道。这时输入被读取,一次一个整数,转换,然后由循环体打印。
transform
的最后一个参数是您必须在源文件中声明或定义的函数名。在这个例子中,每个函数都接受一个int
参数并返回一个int
。transform
函数的一般规则是其参数类型必须匹配输入类型,即输入范围内元素的类型。返回值属于相同或兼容的类型。transform
算法对范围内的每个元素调用一次该函数,并返回新值。
重写字数统计程序有点困难。回想一下清单 20-3 中的内容,sanitize
函数通过删除非字母并将所有大写字母转换成小写字母来转换字符串。C++ 标准库的目的不是提供涵盖所有可能的编程场景的无数函数,而是提供构建自己的函数来解决问题所需的工具。因此,您可能会徒劳地在标准库中搜索一个复制、转换和过滤的算法。相反,您可以组合两个标准函数:一个进行转换,一个进行过滤。
然而,更复杂的是,您知道过滤和转换功能将依赖于一个地区。现在通过将您选择的区域设置为全局区域来解决这个问题。通过调用std::local::global
并传递一个 locale 对象作为唯一的参数来实现。用默认构造器创建的std::locale
对象使用全局语言环境,所以在您的程序将您选择的语言环境设置为全局语言环境后,您可以很容易地注入一个流或者通过std::locale{}
访问所选择的语言环境。任何函数都可以使用全局语言环境,而不必传递locale
对象。清单 22-2 演示了如何重写清单 20-3 以将全局区域设置为本地区域,然后如何在程序的剩余部分使用全局区域。
import <format>;
import <iostream>;
import <locale>;
import <map>;
import <string>;
import <string_view>;
using count_map = std::map<std::string, int>; ///< Map words to counts
using count_pair = count_map::value_type; ///< pair of a word and a count
using str_size = std::string::size_type; ///< String size type
/** Initialize the I/O streams by imbuing them with
* the global locale. Use this function to imbue the streams
* with the native locale. C++ initially imbues streams with
* the classic locale.
*/
void initialize_streams()
{
std::cin.imbue(std::locale{});
std::cout.imbue(std::locale{});
}
/** Find the longest key in a map.
* @param map the map to search
* @returns the size of the longest key in @p map
*/
str_size get_longest_key(count_map const& map)
{
str_size result{0};
for (auto& pair : map)
if (pair.first.size() > result)
result = pair.first.size();
return result;
}
/** Print the word, count, newline. Keep the columns neatly aligned.
* Rather than the tedious operation of measuring the magnitude of all
* the counts and then determining the necessary number of columns, just
* use a sufficiently large value for the counts column.
* @param pair a word/count pair
* @param longest the size of the longest key; pad all keys to this size
*/
void print_pair(count_pair const& pair, str_size longest)
{
int constexpr count_size{10}; // Number of places for printing the count
std::cout << std::format("{1:{0}}{3:{2}}\n",
longest, pair.first, count_size, pair.second);
}
/** Print the results in neat columns.
* @param counts the map of all the counts
*/
void print_counts(count_map const& counts)
{
str_size longest{get_longest_key(counts)};
// For each word/count pair...
for (count_pair pair: counts)
print_pair(pair, longest);
}
/** Sanitize a string by keeping only alphabetic characters.
* @param str the original string
* @return a sanitized copy of the string
*/
std::string sanitize(std::string_view str)
{
std::string result{};
for (char c : str)
if (std::isalnum(c, std::locale{}))
result.push_back(std::tolower(c, std::locale{}));
return result;
}
/** Main program to count unique words in the standard input. */
int main()
{
// Set the global locale to the native locale.
std::locale::global(std::locale{""});
initialize_streams();
count_map counts{};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
{
std::string copy{sanitize(word)};
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
print_counts(counts);
}
Listing 22-2.New main Function That Sets the Global Locale
现在是时候重写sanitize
函数来利用算法了。使用transform
将字符转换成小写。使用filter
仅保留字母字符。一个string_view
是一个字符范围,所以它可以提供一个范围管道。
看一下清单 22-3 ,看看算法在代码中是如何工作的。
/** Test whether to keep a letter.
* @param ch the character to test
* @return true to keep @p ch because it may be a character that makes up a word
*/
bool keep(char ch)
{
return std::isalnum(ch, std::locale{});
}
/** Convert to lowercase.
* @param ch the character to test
* @return the character converted to lowercase
*/
char lowercase(char ch)
{
return std::tolower(ch, std::locale{});
}
/** Sanitize a string by keeping only alphabetic characters.
* @param str the original string
* @return a sanitized copy of the string
*/
std::string sanitize(std::string_view str)
{
auto data{ str
| std::views::filter(keep)
| std::views::transform(lowercase) };
return std::string{ std::ranges::begin(data), std::ranges::end(data) };
}
Listing 22-3.Sanitizing a String by Transforming and Filtering It
范围管道从str
开始一次输入一个字符,并对它们进行过滤,这样只有我们想要保留的字符才能继续通过管道。这些字符然后被转换成小写。管道存储在data
中。
使用管道是方便的,但是最终sanitize()
函数需要返回一个真实的字符串。那么,如何将范围管道中的数据转换成字符串呢?幸运的是,范围库还有begin()
和end()
函数,可以用来制作一个std::string
对象。算法,甚至那些在std::ranges
命名空间中的算法,都是在<algorithm>
中声明的。其他std::ranges
功能在<ranges>
中。
述语
keep
函数是谓词的一个例子。谓词是一个返回bool
结果的函数。这些函数在标准库中有许多用途。
例如,sort
函数按升序对值进行排序。如果您想按降序对数据进行排序,该怎么办?sort
函数允许您提供一个谓词来比较项目。排序谓词(称之为pred
)必须满足以下条件:
-
pred(a, a)
必须是false
(一个常见的错误是实现了<=
而不是<
,违反了这个要求)。 -
如果
pred(a, b)
是true
,而pred(b, c)
是true
,那么pred(a, c)
一定也是true
。 -
参数类型必须与要排序的元素类型相匹配。
-
返回类型必须是
bool
或者是 C++ 可以自动转换成bool
的东西。
如果不提供谓词,sort
将使用<
操作符作为缺省值。
写一个谓词比较两个整数进行降序排序。
写一个程序来测试你的函数。奏效了吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
将您的解决方案与清单 22-4 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
/** Predicate for sorting into descending order. */
int descending(int a, int b)
{
return a > b;
}
int main()
{
std::vector<int> data{std::istream_iterator<int>(std::cin),
std::istream_iterator<int>()};
std::ranges::sort(data, descending);
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 22-4.Sorting into Descending Order
范围管道很漂亮,但是sort()
需要将整个范围存储在一个容器中,比如std::vector
。所以您可以使用istream_view
来读取数据,但是您仍然需要将值存储在一个向量中。但是你不能直接从一个istream_view
或任何其他范围创建一个矢量对象。相反,你可以从开始和结束迭代器初始化一个向量。
sort
使用的默认比较(<
操作符)是整个标准库中进行比较的标准。标准库使用<
作为可以订购的任何东西的订购函数。例如,map
使用<
来比较键。lower_bound
函数(您在探索 13 中使用的)使用<
操作符来执行二分搜索法。
标准库甚至在处理有序值时使用<
来比较对象的相等性,比如映射或二分搜索法。(非固有排序的算法和容器使用==
来确定两个对象何时相等。)为了测试两个项目a
和b
是否相同,这些库函数使用a < b
和b < a
。如果两个比较都是false
,那么a
和b
必须相同,或者用 C++ 的术语来说,等价于。如果你提供一个比较谓词(pred
),如果pred(a, b)
是false
并且pred(b, a)
是false
,那么库认为a
和b
是等价的。
修改你的降序排序程序(或清单 22-4 )使用 ==
作为比较运算符。你预计会发生什么?
用各种输入运行新程序。实际上会发生什么?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
等价性测试被打破,因为descending(a,a)
是true
,不是false
。因为谓词不能正常工作,所以不能保证sort
能够正常工作或者根本不能工作。结果是不确定的。您的程序很可能因为内存冲突而崩溃。无论何时编写谓词,都要确保比较是严格的(即,可以编写有效的等价测试),并且传递性成立(如果a < b
和b < c
,那么a < c
也是true
)。
其他算法
标准库包含了太多有用的算法,本书无法一一介绍,但是我将在本节花点时间向您介绍其中的一些。参考全面的语言参考来了解其他算法。
让我们通过寻找回文来探索算法。回文是向前向后读一样的单词或短语,忽略标点符号,例如:
夫人,我是亚当。
程序通过调用getline
函数一次读取一行文本。这个函数从输入流读入一个字符串,当它读到一个定界符时停止。默认分隔符是'\n'
,所以它读取一行文本。它不跳过起始或结尾空白。
第一步是删除非字母字符,但是您已经知道如何做了。
下一步是测试结果字符串是否是回文。reverse
函数改变一个范围内元素的顺序,比如一个字符串中的字符。
equal
函数比较两个序列以确定它们是否相同。它将两个序列作为参数,并带有一个可选的谓词来比较元素是否相等。在这种情况下,比较必须是不区分大小写的,所以提供一个谓词,在比较之前将所有文本转换为规范的大小写。
去吧。写程序。一个简单的网络搜索应该会提供一些有趣的回文来测试你的程序。如果您无法访问 Web,请尝试以下方法:
-
前夕
-
行动
-
汉纳
-
利昂看见我是诺埃尔
如果你需要一些提示,以下是我的建议:
-
编写一个名为
is_palindrome
的函数,它将一个std::string_view
作为参数并返回一个bool
。 -
这个函数使用
std::views::filter
函数只保留感兴趣的字符。 -
使用
std::views::reverse
创建另一个管道,以相反的顺序遍历字符。 -
std::ranges::equal()
函数接受两个范围,如果它们包含相同的字符序列,则返回 true。 -
main
程序将全局语言环境设置为本地语言环境,并用新的全局语言环境填充输入和输出流。 -
主程序调用
getline(std::cin, line)
直到函数返回false
(意味着错误或文件结束),然后为每一行调用is_palindrome
。
清单 22-5 显示了我完成的程序版本。
import <algorithm>;
import <iostream>;
import <locale>;
import <ranges>;
import <string>;
import <string_view>;
/** Test for letter.
* @param ch the character to test
* @return true if @p ch is a letter
*/
bool letter(char ch)
{
return std::isalpha(ch, std::locale{});
}
/** Convert to lowercase.
* @param ch the character to test
* @return the character converted to lowercase
*/
char lowercase(char ch)
{
return std::tolower(ch, std::locale{});
}
/** Determine whether @p str is a palindrome.
* Only letter characters are tested. Spaces and punctuation don't count.
* Empty strings are not palindromes because that's just too easy.
* @param str the string to test
* @return true if @p str is the same forward and backward
*/
bool is_palindrome(std::string_view str)
{
auto letters_only{ str | std::views::filter(letter) };
auto lowercased{ letters_only | std::views::transform(lowercase) };
auto reversed{ lowercased | std::views::reverse };
return std::ranges::equal(lowercased, reversed);
}
int main()
{
std::locale::global(std::locale{""});
std::cin.imbue(std::locale{});
std::cout.imbue(std::locale{});
std::string line{};
while (std::getline(std::cin, line))
if (is_palindrome(line))
std::cout << line << '\n';
}
Listing 22-5.Testing for Palindromes
范围和范围适配器是 C++ 20 的一个漂亮的新特性。但是正如我们在清单 22-4 中看到的,有时你需要使用普通迭代器。因此,看看如何只用迭代器实现相同的程序和函数是有启发性的,这就是我们用 C++ 17 编写它们的方式。下一篇文章将带您浏览相同的例子,但是只使用迭代器。看你更喜欢哪种方式。
二十三、使用迭代器
在前面的探索中,您已经看到了范围适配器和范围算法是如何工作的。大多数范围算法,比如sort()
,很可能是在基于迭代器的算法之上实现的。即使在处理范围时,您仍然需要使用迭代器,比如ostream_iterator
。有时候,迭代器比范围更容易使用,比如初始化一个向量。这个探索访问了迭代器和算法,它们提供了范围的替代方案。
转换数据
您读过和写过的几个程序都有一个共同的主题:复制一个数据序列,比如一个vector
或string
,并对每个元素应用某种转换(转换成小写,将数组中的值加倍,等等)。标准算法transform
非常适合对序列元素进行任意复杂的转换。
例如,回想一下清单 10-5,它将一个数组中的所有值加倍。清单 23-1 展示了一种新的方式来编写同样的程序,但是使用了transform
。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
int times_two(int i)
{
return i * 2;
}
int plus_three(int i)
{
return i + 3;
}
int main()
{
std::vector<int> data{std::istream_iterator<int>(std::cin),
std::istream_iterator<int>()};
std::transform(data.begin(), data.end(), data.begin(), times_two);
std::transform(data.begin(), data.end(), data.begin(), plus_three);
std::copy(data.begin(), data.end(),
std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 23-1.Calling transform to Apply a Function to Each Element of an Array
transform
函数有四个参数:前两个指定输入范围(作为开始迭代器和结束迭代器),第三个参数是写迭代器,最后一个参数是函数名。像其他基于迭代器的算法一样,transform
在<algorithm>
头中声明。
关于第三个参数,通常,您有责任确保输出序列有足够的空间来容纳转换后的数据。在这种情况下,转换后的数据会覆盖原始数据,因此输出范围的起点与输入范围的起点相同。第四个参数只是您必须在源文件中声明或定义的函数的名称。在这个例子中,函数接受一个int
参数并返回一个int
。transform
函数的一般规则是它的参数类型必须匹配输入类型,也就是读迭代器引用的元素的类型。返回值必须匹配输出类型,即结果迭代器引用的类型。transform
算法为输入范围内的每个元素调用一次该函数。它将函数返回的值复制到输出范围。
请注意,这个版本的程序为了应用两个转换,在这个范围上做了额外的处理。该程序的范围版本可以一次完成两种转换。为了进行一次传递,你需要一个函数,所以你可以写一个函数乘以 2,然后加上 3。在接下来的探索中,你会学到更好的方法来做同样的事情。
重写字数统计程序很简单。该程序基本上与清单 22-2 相同。不同的是sanitize()
函数。清单 22-3 显示了范围版本。清单 23-2 显示了迭代器版本。
/** Test whether to keep a letter.
* @param ch the character to test
* @return true to keep @p ch because it may be a character that makes up a word
*/
bool keep(char ch)
{
return std::isalnum(ch, std::locale{});
}
/** Convert to lowercase.
* @param ch the character to test
* @return the character converted to lowercase
*/
char lowercase(char ch)
{
return std::tolower(ch, std::locale{});
}
/** Sanitize a string by keeping only alphabetic characters.
* @param str the original string
* @return a sanitized copy of the string
*/
std::string sanitize(std::string_view str)
{
std::string result{};
std::copy_if(str.begin(), str.end(), std::back_inserter(result), keep);
std::transform(result.begin(), result.end(), result.begin(), lowercase);
return result;
}
Listing 23-2.Sanitizing a String by Transforming and Filtering It
copy_if
函数充当过滤器,只复制通过谓词的字符。然后,这些字符被添加到结果字符串中。但是在返回之前,结果字符串被就地转换为小写。如你所见,sanitize()
的迭代器版本还不错。这是清晰和直接的,尽管有大量的重复和噪音,稍微干扰了清晰度。该函数再次对数据进行额外的传递。使用迭代器避免额外的传递比使用范围更困难。
sanitize()
函数的另一种工作方式是传递一个std::string
而不是一个string_view
。如前所述,这需要复制字符串,但是sanitize
函数已经在做了。让我们来看看如果给它一个string
它会如何工作。
不是过滤,而是必须从字符串中删除非字母。然后就可以就地转化了,这个我们已经知道怎么做了。remove_if()
算法似乎删除了匹配谓词的字符,但是真的是这样吗?
图 23-1 展示了remove_if()
如何在之前的和之后的下工作。注意remove_if()
函数没有改变字符串的大小。相反,它会重新排列字符串,使其看起来删除了字符,并返回必须是字符串新结尾的位置。但是迭代器不能修改字符串的大小,所以这取决于调用者。在这方面,使用迭代器可能很笨拙。
图 23-1。
从序列中移除元素
字符串末尾剩下的字符呢?他们是垃圾。这就是为什么你必须在remove_if()
之后调用erase()
。看看清单 23-3 ,看看remove_if()
在代码中是如何工作的。
/** Test for non-letter.
* @param ch the character to test
* @return true if @p ch is not a character that makes up a word
*/
bool non_letter(char ch)
{
return not std::isalnum(ch, std::locale());
}
/** Convert to lowercase.
* Use a canonical form by converting to uppercase first,
* and then to lowercase.
* @param ch the character to test
* @return the character converted to lowercase
*/
char lowercase(char ch)
{
return std::tolower(ch, std::locale());
}
/** Sanitize a string by keeping only alphabetic characters.
* @param str the original string
* @return a sanitized copy of the string
*/
std::string sanitize(std::string str)
{
// Remove all non-letters from the string, and then erase them.
str.erase(std::remove_if(str.begin(), str.end(), non_letter),
str.end());
// Convert the remnants of the string to lowercase.
std::transform(str.begin(), str.end(), str.begin(), lowercase);
return str;
}
Listing 23-3.Sanitizing a String by Transforming It
erase
成员函数将两个迭代器作为参数,并删除该范围内的所有元素。remove_if
函数返回一个迭代器,它指向新的string
末尾之后的一个迭代器,这意味着它也指向要删除的元素的第一个位置。在范围结束时传递str.end()
指示erase
去掉所有被删除的元素。
移除/擦除习惯用法在 C++ 中很常见,所以你应该习惯于看到它,至少在每个人都开始使用 C++ 20 范围之前。标准库有几个类似 remove 的函数,它们都以相同的方式工作。习惯这种方法需要一点时间,但是一旦你习惯了,你会发现它非常容易使用。
用迭代器排序
现在您已经看到了迭代器是如何工作的,应该很容易修改清单 22-4 来使用迭代器而不是范围。将您的解决方案与清单 23-4 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
/** Predicate for sorting into descending order. */
int descending(int a, int b)
{
return a > b;
}
int main()
{
std::vector<int> data{ std::istream_iterator<int>(std::cin),
std::istream_iterator<int>() };
std::sort(data.begin(), data.end(), descending);
std::copy(data.begin(), data.end(), std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 23-4.Sorting into Descending Order
将范围扩展为开始/结束对是一个简单的转换。回文的例子呢?这有多简单?转换清单 22-5 来使用迭代器。
这更难。迭代器没有过滤。像copy_if
这样的算法必须将复制的字符存储在某个地方。使用迭代器算法需要创建一个新的字符串。或者,你可以完全跳过算法,只使用迭代器。清单 23-5 展示了我版本的使用反向迭代器的is_palindrome()
函数。
/** Determine whether @p str is a palindrome.
* Only letter characters are tested. Spaces and punctuation don't count.
* @param str the string to test
* @return true if @p str is the same forward and backward
*/
bool is_palindrome(std::string_view str)
{
if (str.empty())
return true;
for (auto left{str.begin()}, right{str.end() - 1}; left < right;) {
if (not letter(*left))
++left;
else if (not letter(*right))
--right;
else if (lowercase(*left) != lowercase(*right))
return false;
else {
++left;
--right;
}
}
return true;
}
Listing 23-5.Testing for Palindromes
通过立即消除空字符串,for
循环可以用end() - 1
初始化right
,即实际最后一个字符的位置。每次循环时,left
或right
迭代器都会向前移动,直到都指向字母。然后比较字母,移动迭代器。如果字符串有偶数个字母,迭代器可能会相互传递,但是只要它们指向字符串中的有效位置,就可以安全地使用<
操作符。
在大型程序中,谓词或转换函数可能在远离其使用位置的地方被声明。通常,一个谓词只使用一次。仅仅为这个谓词定义一个函数,会使你的程序更难理解。人类读者必须阅读所有代码,以确保谓词真正只在一个地方被调用。如果 C++ 提供了一种在使用谓词的地方编写谓词的方法,从而避免这些问题,那就太好了。阅读下一篇探索,了解如何在 C++ 20 中实现这一点。
二十四、匿名函数
调用算法的一个问题是,有时谓词或转换函数必须在远离它被调用的地方声明。有了一个合适的描述性名称,这个问题就可以解决了,但是这个函数通常是琐碎的,如果您可以将它的功能直接放在对标准算法的调用中,那么您的程序会更容易阅读。C++ 11 中引入并在随后的标准中扩展的一个特性正好允许这一点。
希腊字母的第 11 个
C++ 20 允许你将一个函数定义为一个表达式。你可以把这个函数传递给一个算法,保存在一个变量里,或者直接调用它。这样的函数被称为λ,其原因只有计算机科学家才会理解甚至关心。如果你不是计算机科学家,不要担心,只要意识到当书呆子谈论 lambdas 时,他们只是在谈论未命名的函数。作为快速介绍,清单 24-1 重写了清单 22-1 以使用 lambdas。
import <iostream>;
import <iterator>;
import <ranges>;
int main()
{
auto data{ std::ranges::istream_view<int>(std::cin)
| std::views::transform([](int i) { return i * 2; })
| std::views::transform([](int i) { return i + 3; })
};
for (auto element : data)
std::cout << element << '\n';
}
Listing 24-1.Calling transform to Apply a Lambda to Each Element of an Array
lambda 看起来几乎像一个函数定义。lambda 以方括号开始,而不是函数名。通常的函数参数和复合语句如下。少了什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
没错,函数的返回类型。编译器试图从返回表达式的类型中推断出函数的类型。在这种情况下,返回类型是int
。
有了 lambda,程序稍微短一些,也更容易阅读。你不必搜寻times_two()
的定义来了解它的作用。(并不是所有的函数都起得这么清楚。)但是 lambdas 更强大,可以做普通函数做不到的事情。看看清单 24-2 就明白我的意思了。
import <algorithm>;
import <iostream>;
import <iterator>;
import <ranges>;
import <vector>;
int main()
{
std::cout << "Multiplier: ";
int multiplier{};
std::cin >> multiplier;
auto data{
std::ranges::istream_view<int>(std::cin)
| std::views::transform(multiplier { return i * multiplier; })
};
std::cout << "Data:\n";
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 24-2.Using a Lambda to Access Local Variables
如果程序的输入如下,预测输出:
4 1 2 3 4 5
第一个数字是乘数,其余的数字乘以它,得到
4
8
12
16
20
看出窍门了吗?lambda 能够读取本地变量multiplier
。一个单独的函数,比如清单 22-1 中的times_two()
,做不到这一点。当然,您可以向times_two()
传递两个参数,但是使用transform
算法调用只有一个参数的函数。有一些方法可以解决这个限制,但是我不会向您展示它们,因为 lambdas 简单而优雅地解决了这个问题。
命名未命名的函数
虽然 lambda 是一个未命名的函数,但是您可以通过将 lambda 赋给一个变量来给它命名。在这种情况下,您可能希望使用auto
关键字声明变量,因此您不必考虑作为变量初始值的 lambda 的类型:
auto times_three = [](int i) { return i * 3; };
一旦将 lambda 赋值给变量,就可以像调用普通函数一样调用该变量:
int forty_two{ times_three(14) };
命名 lambda 的好处是你可以在同一个函数中多次调用它。通过这种方式,您可以获得使用精心选择的名称进行自文档化代码的好处,以及本地定义的好处。
如果不想用auto
,标准库可以帮忙。在<functional>
头中是类型std::function
,通过将函数的返回类型和参数类型放在尖括号中来使用它,例如std::function<int(int)>
。例如,下面定义了一个变量times_two
,并用一个 lambda 初始化它,该 lambda 接受一个类型为int
的参数并返回int
:
std::function<int(int)> times_two{ [](int i) { return i * 2; } };
lambda 的实际类型更复杂,但是编译器知道如何将该类型转换成匹配的std::function<>
类型。使用auto
是首选,因为调用std::function
会产生一点成本,而auto
lambda 不会。
捕获局部变量
在 lambda 的方括号中命名一个局部变量叫做捕获变量的值。如果没有捕获变量,就不能在 lambda 中使用它,所以 lambda 只能使用它的函数参数。
读取清单中的程序 24-3 。想想它是如何捕捉局部变量multiplier
的。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
int main()
{
std::vector<int> data{ 1, 2, 3 };
int multiplier{3};
auto times = multiplier { return i * multiplier; };
std::ranges::transform(data, data.begin(), times);
multiplier = 20;
std::ranges::transform(data, data.begin(), times);
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 24-3.Using a Lambda to Access Local Variables
清单 24-3 调用另一种风格的transform()
。和迭代器版本一样,它有一个输入和一个输出,但是输入是一个范围,输出是一个迭代器。在这种情况下,转换后的值会覆盖data
,将其就地转换。其行为与std::views::transform
相同,为范围内的每个元素调用其函数参数,并将该元素写入输出迭代器。预测清单 24-3 的输出。
现在运行程序。你的预测正确吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _为什么或者为什么不?
定义 lambda 时,value 捕获了multiplier
的值。因此,稍后改变multiplier
的值不会改变λ,它仍然乘以 3。transform()
算法被调用了两次,所以效果是乘以 9,而不是 60。
如果需要 lambda 跟踪局部变量并始终使用其最近的值,可以通过在变量名称前加一个“与”号(类似于引用函数参数)来引用捕获变量,如下例所示:
&multiplier { return i * multiplier; };
修改清单 24-3 参照捕捉 multiplier
。运行程序,观察它的新行为。
您可以选择省略捕获名称来捕获所有局部变量。使用等号按值获取所有内容,或者使用&符号按引用获取所有内容。
int x{0}, y{1}, z{2};
auto capture_all_by_value = [=]() { return x + y + z; };
auto capture_all_by_reference = [&]() { x = y = z = 0; };
我建议不要默认捕获所有内容,因为这会导致松散的代码。明确 lambda 捕获的变量。捕获列表应该很短,否则你可能做错了什么。尽管如此,您可能会看到其他程序员捕获一切,即使只是出于懒惰,所以我必须向您展示语法。
如果您遵循最佳实践并列出各个捕获名称,默认情况下是按值捕获,因此您必须为每个要通过引用捕获的名称提供一个&符号。随意混合按值捕获和按引用捕获。
auto lambda =
[by_value, &by_reference, another_by_value, &another_by_reference]() {
by_reference = by_value;
another_by_reference = another_by_value;
};
常量捕获
按价值捕捉有一个锦囊妙计,可以让你大吃一惊。考虑清单 24-4 中的简单程序。
import <iostream>;
int main()
{
int x{0};
auto lambda = x {
x = 1;
y = 2;
return x + y;
};
int local{0};
std::cout << lambda(local) << ", " << x << ", " << local << '\n';
}
Listing 24-4.Using a Lambda to Access Local Variables
运行该程序时,您预计会发生什么?
有什么惊喜?
您已经知道函数参数是按值调用的,所以在 lambda 之外,y = 2
赋值无效,并且local
保持为 0。按值捕获是类似的,因为你不能改变被捕获的局部变量(清单 24-4 中的x
)。但是编译器比这更挑剔。它不让你写作业x = 1
。好像每个按值捕获都被声明为const
。
Lambdas 与普通函数的不同之处在于,按值捕获的默认值是const
,要获得非const
捕获,必须显式告诉编译器。要使用的关键字是mutable
,放在函数参数之后,如清单 24-5 所示。
import <iostream>;
int main()
{
int x{0};
auto lambda = x mutable {
x = 1;
y = 2;
return x + y;
};
int local{0};
std::cout << lambda(local) << ", " << x << ", " << local << '\n';
}
Listing 24-5.Using the mutable Keyword in a Lambda
现在编译器让您将x
赋值给捕获。捕获仍然是按值的,所以main()
中的x
不会改变。该程序的输出是
3, 0, 0
到目前为止,我还没有找到一个想用mutable
的实例。如果你需要它,它就在那里,但你可能永远也不会需要它。
返回类型
如果 lambda 主体只包含一个return
语句,那么 lambda 的返回类型就是return
表达式的类型。但是如果 lambda 更复杂,编译器无法确定返回类型,该怎么办呢?lambda 的语法不适于以通常的方式声明函数返回类型。相反,返回类型跟在函数参数列表后面,在右括号和返回类型之间有一个箭头(->
):
[](int i) -> int { return i * 2; }
一般来说,lambda 在没有显式返回类型的情况下更容易阅读。返回类型通常是显而易见的,但如果不是,就直接显式返回。清晰胜过简洁。
改写清单 22-5 利用兰姆达斯。在你认为函数合适的地方写函数,在你认为 lambdas 合适的地方写 lambdas。在清单 24-6 中将您的解决方案与我的进行比较。
import <algorithm>;
import <iostream>;
import <locale>;
import <ranges>;
import <string>;
import <string_view>;
/** Determine whether @p str is a palindrome
.
* Only letter characters are tested. Spaces and punctuation don't count.
* Empty strings are not palindromes because that's just too easy.
* @param str the string to test
* @return true if @p str is the same forward and backward
*/
bool is_palindrome(std::string_view str)
{
auto letters_only{ str
| std::views::filter([](char c) { return std::isalnum(c, std::locale{}); })
| std::views::transform([](char c) { return std::tolower(c, std::locale{}); })
};
auto reversed{ letters_only | std::views::reverse };
return std::ranges::equal(letters_only, reversed);
}
int main()
{
std::locale::global(std::locale{""});
std::cin.imbue(std::locale{});
std::cout.imbue(std::locale{});
std::string line{};
while (std::getline(std::cin, line))
if (is_palindrome(line))
std::cout << line << '\n';
}
Listing 24-6.Testing for Palindromes
到目前为止,您可能已经习惯了以多种形式看到同一个函数,比如有或没有显式谓词的sort()
。将一个名字用于多个函数被称为重载。这是下一步探索的主题。
二十五、重载函数名
在 C++ 中,如果函数具有不同数量的参数或不同的参数类型,则多个函数可以具有相同的名称。对多个函数使用相同的名字被称为重载,在 C++ 中很常见。
过载
所有编程语言都在某种程度上使用重载。例如,大多数语言使用+
进行整数加法和浮点加法。有些语言,如 Pascal,对整数除法(div
)和浮点除法(/
)使用不同的运算符,但其他语言,如 C 和 Java,使用相同的运算符(/
)。
C++ 将重载向前推进了一步,允许重载自己的函数名。明智地使用重载可以大大降低程序的复杂性,使程序更容易阅读和理解。
例如,C++ 从标准 C 库中继承了几个计算绝对值的函数:abs
接受一个int
参数;fabs
采取浮点论证;而labs
需要一个长整型参数。
Note
不要担心我还没有介绍这些其他类型。对于这个讨论的目的来说,重要的是它们与int
不同。下一次探索将开始更仔细地检查它们,所以请耐心等待。
C++ 对于复数也有自己的complex
类型,有自己的绝对值函数。然而在 C++ 中,它们都有相同的名字,std::abs
。对不同的类型使用不同的名称只会使思维混乱,对代码的清晰性没有任何帮助。
仅举一个例子,sort
函数有两种重载形式:
std::sort(start, end);
std::sort(start, end, compare);
(std::ranges::sort
功能因使用ranges
而不同,这将在探索 47 中解释。)第一个表单按升序排序,用<
操作符比较元素,第二个表单通过调用compare
比较元素。重载出现在标准库中的许多其他地方。例如,当您创建一个locale
对象时,您可以通过不传递参数来复制全局语言环境
std::isalpha('X', std::locale{});
或者通过传递空字符串参数来创建本机区域设置对象
std::isalpha('X', std::locale{""});
重载函数很容易,为什么不直接加入呢?写一组函数,都命名为 print。它们都有一个void
返回类型并接受各种参数:
-
一个将一个
int
作为参数。它将参数打印到标准输出。 -
另一个需要两个
int
参数。它将第一个参数打印到标准输出,并将第二个参数用作字段宽度。 -
另一个将一个
vector<int>
作为第一个参数,后面跟着三个string_view
参数。打印第一个string_view
参数,然后是vector
的每个元素(通过调用print
),元素之间是第二个string_view
参数,第三个string_view
参数在vector
之后。如果vector
为空,仅打印第一个和第三个string_view
参数。 -
另一个与
vector
表单具有相同的参数,但是也采用一个int
作为每个vector
元素的字段宽度。
使用打印功能编写一个程序来打印矢量。将你的功能和程序与清单 25-1 中我的进行比较。
import <iostream>;
import <string_view>;
import <vector>;
void print(int i)
{
std::cout << i;
}
void print(int i, int width)
{
std::cout.width(width);
std::cout << i;
}
void print(std::vector<int> const& vec,
int width,
std::string_view prefix,
std::string_view separator,
std::string_view postfix)
{
std::cout << prefix;
bool print_separator{false};
for (auto x : vec)
{
if (print_separator)
std::cout << separator;
else
print_separator = true;
print(x, width);
}
std::cout << postfix;
}
void print(std::vector<int> const& vec,
std::string_view prefix,
std::string_view separator,
std::string_view postfix)
{
print(vec, 0, prefix, separator, postfix);
}
int main()
{
std::vector<int> data{ 10, 20, 30, 40, 100, 1000, };
std::cout << "columnar data:\n";
print(data, 10, "", "\n", "\n");
std::cout << "row data:\n";
print(data, "{", ", ", "}\n");
}
Listing 25-1.Printing Vectors by Using Overloaded Functions
C++ 库经常使用重载。例如,您可以通过调用resize
成员函数来改变vector
的大小。您可以传递一两个参数:第一个参数是vector
的新大小。如果您传递第二个参数,它是一个用于新元素的值,以防新的大小大于旧的大小。
data.resize(10); // if the old size < 10, use default of 0 for new elements
data.resize(20, -42); // if the old size < 20, use -42 for new elements
库作者经常使用重载,但是应用程序程序员很少使用它。通过编写以下函数来练习编写库:
bool is_alpha(char ch)
如果ch
是全球语言环境中的字母字符,则返回true
;如果没有,返回false
。
bool is _ alpha(STD::string _ view str)
如果str
只包含全球语言环境中的字母字符,则返回true
;如果任何字符不是字母,则返回false
。如果str
为空,则返回true
。
char to_lower(char ch)
如果可能,将其转换为小写后返回ch
;否则,返回ch
。使用全球语言环境。
STD::string to _ lower(STD::string _ view str)
将str
的内容转换成小写字母后,每次返回一个字符。逐字复制任何不能转换成小写的字符。
char to_upper(char ch)
如果可能的话,转换成大写后返回ch
;否则,返回ch
。使用全球语言环境。
STD::string to _ upper(STD::string _ view str)
将str
的内容转换成大写后,返回其副本,一次一个字符。逐字复制任何不能转换为大写的字符。
将您的解决方案与我的进行比较,如清单 25-2 所示。
import <algorithm>;
import <iostream>;
import <locale>;
import <ranges>;
import <string>;
import <string_view>;
bool is_alpha(char ch)
{
return std::isalpha(ch, std::locale{});
}
bool is_alpha(std::string_view str)
{
return std::ranges::all_of(str, [](char c) { return is_alpha(c); });
}
char to_lower(char ch)
{
return std::tolower(ch, std::locale{});
}
std::string to_lower(std::string_view str)
{
auto data{str | std::views::transform([](char c) { return to_lower(c); })};
return std::string{ std::ranges::begin(data), std::ranges::end(data) };
}
char to_upper(char ch)
{
return std::toupper(ch, std::locale{});
}
std::string to_upper(std::string str)
{
for (char& ch : str)
ch = to_upper(ch);
return str;
}
int main()
{
std::string str{};
while (std::cin >> str)
{
if (is_alpha(str))
std::cout << "alpha\n";
else
std::cout << "not alpha\n";
std::cout << "lower: " << to_lower(str) << "\n"
"upper: " << to_upper(str) << '\n';
}
}
Listing 25-2.Overloading Functions in the Manner of a Library Writer
我通过调用std::ranges::all_of
算法实现了to_lower
,该算法为一个范围内的每个元素调用一个谓词,如果谓词为所有元素返回 true,则返回 true。如果谓词返回 false,那么 all_of 将停止对该范围的迭代,并立即返回 false。
为了多样化,我实现了完全不同的to_upper
。我使用了一个扭转的远程for
循环。当循环迭代字符串时,这个循环实际上修改了字符元素。像按引用传递函数参数一样,声明char& ch
是对范围内字符的引用。因此,赋值给ch
会改变字符串中的字符。注意auto
也可以声明一个引用。如果范围中的每个元素都很大,应该使用引用来避免不必要的复制。如果不需要修改元素,使用const
参考,如下所示:
for (auto const& big_item : container_full_of_big_things)
另一个区别是to_upper
采用一个普通的string
作为它的参数。这意味着参数通过值传递,这又意味着编译器在将参数传递给函数时安排复制字符串。该函数需要复制,因此这种技术有助于编译器生成复制参数的最佳代码,并为您节省了编写函数的步骤。这是一个小技巧,但很有用。这项技术在本书的后面会特别有用——所以不要忘记它。
is_alpha
字符串函数不修改它的参数,所以它可以使用string_view
类型。
重载的一个常见用途是重载不同类型的函数,包括不同的整数类型,如长整型。下一篇文章将研究这些其他类型。
二十六、大数字和小数字
重载的另一个常见用途是编写函数,这些函数可以很好地处理大整数和小整数,就像处理普通整数一样。C++ 有五种不同的整数类型,大小从 8 位到 64 位或更大,在两者之间有几种选择。这一探索着眼于细节。
总而言之
int
的大小是主机平台上整数的自然大小。对于你的台式电脑,这可能意味着 32 位或 64 位。不久前,它意味着 16 位或 32 位。我也用过 36 位和 60 位整数的计算机。在台式计算机和工作站领域,32 位和 64 位处理器主导了今天的计算格局,但不要忘记专用设备,如数字信号处理器(DSP)和其他嵌入式芯片,其中 16 位架构仍然很常见。让标准保持灵活的目的是为了确保代码的最佳性能。C++ 标准保证一个int
至少可以表示 32,768 到 32,767 范围内的任何数字,也就是说int
的最小长度是 16 位。
你用来做练习的电脑很可能实现了大于 16 位的int
。要发现一个整数的位数,使用std::numeric_limits
,就像你在清单 2-3 中所做的那样。尝试同样程序,但是用 int
代替 bool
。你的输出得到了什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
最有可能的是,你得到了 31 个,尽管你们中的一些人可能已经看到了 15 或 63 个。原因是digits
不计算符号位。有符号整数使用一种称为二进制补码的表示法,如果数字是负数,它会将最高有效位设置为 1。因此,对于表示有符号数量的类型,如int
,您必须在digits
上加 1,对于没有符号的类型,如bool
,使用digits
,无需进一步修改。好在std::numeric_limits
提供了is_signed
,对于有符号类型是true
,对于没有符号位的类型是false
。重写清单2-3以使用 is_signed 来确定是否给数字加 1,并打印每个 int
和每个 bool
的位数。
检查你的答案。他们是正确的吗? ________________ 将你的程序与清单 26-1 进行比较。
import <iostream>;
import <limits>;
int main()
{
std::cout << "bits per int = ";
if (std::numeric_limits<int>::is_signed)
std::cout << std::numeric_limits<int>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<int>::digits << '\n';
std::cout << "bits per bool = ";
if (std::numeric_limits<bool>::is_signed)
std::cout << std::numeric_limits<bool>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<bool>::digits << '\n';
}
Listing 26-1.Discovering the Number of Bits in an Integer
长整数
有时,你需要比int
能处理的更多的位。在这种情况下,将long
添加到定义中得到一个长整数。
long int lots_o_bits{2147483647};
你甚至可以放下int
,如下图所示:
long lots_o_bits{2147483647};
该标准保证了一个long int
可以处理 32 位的数字,也就是在-2,147,483,648 到 2,147,483,647 范围内的值,但是一个实现可以选择更大的大小。C++ 不保证一个long int
实际上比一个普通的int
长。在某些平台上,int
可能是 32 位,long
可能是 64 位。我第一次在家里用 PC 的时候,一个int
是 16 位,long
是 32 位。有时,我使用的系统的int
和long
都是 32 位。我在一台使用 32 位的int
和 64 位的long
的机器上写这本书。
类型long long int
可以更大(64 位),范围至少是–9223372036854775808 到 9223372036854775807。如果你愿意,你可以去掉int
,程序员经常这么做。
如果您希望存储尽可能多的数字,并且愿意付出较小的性能代价(在某些系统上,或者在其他系统上付出较大代价),请使用long long
。如果您必须确保可移植性,并且必须表示大于 16 位的数字,请使用long
。
短整数
有时候,你没有一个int
的全范围,减少内存消耗更重要。在这种情况下,使用short int
,或仅使用short
,其保证范围至少为–32,768 到 32,767,包括这两个值。这与int
的保证范围相同,但实现通常选择使int
大于最小值,并将short
保持在 16 位的范围内。但是你千万不要假设int
总是大于short
。两者的尺寸可能完全相同。
正如对long
所做的那样,您将类型定义为short int
或short
。
short int answer{42};
short zero{0};
修改清单 26-1 来打印long
和short
中的位数。在你的系统中,一个long
有多少位?______________ 一个short
有几个?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ long long
?__________
当我运行清单 26-2 中的程序时,我在short
中得到 16 位,在int
中得到 32 位,在long
和long long
中得到 64 位。在我网络中的另一台计算机上,我在一个short
中得到 16 位,在一个int
和long
中得到 32 位,在一个long long
中得到 64 位。
import <iostream>;
import <limits>;
int main()
{
std::cout << "bits per int = ";
if (std::numeric_limits<int>::is_signed)
std::cout << std::numeric_limits<int>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<int>::digits << '\n';
std::cout << "bits per bool = ";
if (std::numeric_limits<bool>::is_signed)
std::cout << std::numeric_limits<bool>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<bool>::digits << '\n';
std::cout << "bits per short int = ";
if (std::numeric_limits<short>::is_signed)
std::cout << std::numeric_limits<short>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<short>::digits << '\n';
std::cout << "bits per long int = ";
if (std::numeric_limits<long>::is_signed)
std::cout << std::numeric_limits<long>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<long>::digits << '\n';
std::cout << "bits per long long int = ";
if (std::numeric_limits<long long>::is_signed)
std::cout << std::numeric_limits<long long>::digits + 1 << '\n';
else
std::cout << std::numeric_limits<long long>::digits << '\n';
}
Listing 26-2.Revealing the Number of Bits in Short and Long Integers
整数文字
当你写一个整数文字(即整数常量)时,类型依赖于它的值。如果值符合一个int
,则类型为int
;否则,类型为long
或long long
。您可以通过在数字后面添加l
或L
(小写或大写的字母 L )来强制文本具有类型long
。(奇怪的是,C++ 没有办法让你输入一个short
文字。)我总是用大写的L
,因为小写的l
看起来太像数字1
。编译器总是能看出区别,但是每年我都很难看出1
和l
之间的区别。对一个long long
使用两个连续的 1。
设计一种方式,让程序打印 int=
,后跟值,为一个 int
文字;打印 long=
,后跟值,为一个 long
的字面量;并且打印 long long=
,后跟值,为一个 long long
的字面量。(提示:之前探索的主题是什么?)编写一个程序来演示你的想法,并用一些文字进行测试。如果可以的话,在对int
和long
使用不同尺寸的平台上运行程序。将您的程序与清单 26-3 中的程序进行比较。
import <iostream>;
import <locale>;
void print(int value)
{
std::cout << "int=" << value << '\n';
}
void print(long value)
{
std::cout << "long=" << value << '\n';
}
void print(unsigned long value)
{
std::cout << "unsigned long=" << value << '\n';
}
void print(long long value)
{
std::cout << "long long=" << value << '\n';
}
int main()
{
std::cout.imbue(std::locale{""});
print(0);
print(0L);
print(32768);
print(-32768);
print(2147483647);
print(-2147483647);
print(2147483648);
print(9223372036854775807);
print(-9223372036854775807);
}
Listing 26-3.Using Overloading to Distinguish Types of Integer Literals
我加入了类型unsigned
long
,因为有些编译器需要它。一个unsigned
整型永远不会是负的,并且和它的正常(或signed
)等价类型占用相同的位数。因此,它可以保存大约两倍于最大有符号值的值。您的编译器可能会将2147483648
视为unsigned long
,因为它对于普通的long
来说太大了。或者你的编译器可能认为它是一个很小的值。探索 67 会有更多关于unsigned
的话要说。编译器选择的实际类型会有所不同。你的编译器甚至可以处理更大的整数。C++ 标准为每种整数类型设定了一些有保证的范围,所以清单 26-3 中的所有值都适用于一个像样的 C++ 编译器。如果你坚持有保证的范围,你的程序将在任何地方编译和运行;在范围之外,你在碰运气。库的作者必须特别小心。你永远不知道在小型嵌入式处理器上工作的人什么时候会喜欢你的代码并想要使用它。
字节大小的整数
C++ 提供的最小整数类型是signed char
。类型名看起来类似于字符类型char
,但是类型的行为不同。它通常表现得像一个整数。根据定义,signed char
的大小是 1 字节,这是 C++ 编译器支持的任何类型的最小大小。signed char
的保证范围是–128 到 127。
尽管名不副实,你还是尽量不要把signed char
想成变异的字符类型;相反,可以将其视为拼写错误的整数类型。许多程序都有类似于
using byte = signed char;
为了便于您将此类型视为字节大小的整数类型。C++ 定义了自己的std::byte
类型,但是那个类型是针对未解释的数据,而不是小整数。
没有简单的方法来编写一个signed char
文字,就像没有简单的方法来编写一个简单的short
文字一样。字符文字有类型char
,没有类型signed char
。此外,有些字符可能超出了signed char
的范围。
尽管编译器尽力帮助你记住signed char
不是一个char
,但是标准库帮助不大。I/O 流类型将signed char
值视为字符。不管怎样,你必须通知流你想打印一个整数,而不是一个字符。您还需要一个创建signed char
(和short
)文字的解决方案。幸运的是,同样的解决方案允许您使用signed char
常量并打印signed char
数字:类型转换。
铅字铸造
虽然您不能直接编写一个short
或任意的signed char
文字,但是您可以编写一个常量表达式,它具有类型short
或signed char
并取任何合适的值。诀窍是使用一个简单的int
并准确地告诉编译器你想要什么类型。
static_cast<signed char>(-1)
static_cast<short int>(42)
表达式不必是文字,如下所示:
int x{42};
static_cast<short>(x);
这个static_cast
表达式被称为类型转换。运算符static_cast
是保留关键字。它将表达式从一种类型转换为另一种类型。名字中的“静态”意味着该类型在编译时是静态的,或者是固定的。
您可以将任何整数类型转换为任何其他整数类型。如果这个值超出了目标类型的范围,那么结果就是垃圾。例如,可以丢弃高阶位。因此,在使用static_cast
时,你应该始终小心。绝对确保你没有丢弃重要的信息。
如果将一个数字转换为bool
,如果数字为零,结果为false
,如果数字不为零,结果为true
(就像使用整数作为条件时发生的转换一样)。
改写清单 26-3 将过载打印为 short
和 signed char
值太。使用类型转换将各种值强制转换为不同的类型,并确保结果符合您的期望。看一下清单 26-4 来看看一个可能的解决方案。
import <iostream>;
import <locale>;
using byte = signed char;
void print(byte value)
{
// The << operator treats signed char as a mutant char, and tries to
// print a character. In order to print the value as an integer, you
// must cast it to an integer type.
std::cout << "byte=" << static_cast<int>(value) << '\n';
}
void print(short value)
{
std::cout << "short=" << value << '\n';
}
void print(int value)
{
std::cout << "int=" << value << '\n';
}
void print(long value)
{
std::cout << "long=" << value << '\n';
}
void print(unsigned long value)
{
std::cout << "unsigned long=" << value << '\n';
}
void print(long long value)
{
std::cout << "long long=" << value << '\n';
}
int main()
{
std::cout.imbue(std::locale{""});
print(0);
print(0L);
print(static_cast<short>(0));
print(static_cast<byte>(0));
print(static_cast<byte>(255));
print(static_cast<short>(65535));
print(32768);
print(32768L);
print(-32768);
print(2147483647);
print(-2147483647);
print(2147483648);
print(9223372036854775807);
print(-9223372036854775807);
}
Listing 26-4.Using Type Casts
当我运行清单 25-4 时,我得到了static_cast<short>(65535)
和static_cast<byte>(255)
的-1
。这是因为这些值超出了目标类型的范围。最大整数的位模式与–1 的位模式相同。
组成你自己的文字
尽管 C++ 没有提供创建short
文字的内置方法,但是您可以定义自己的文字后缀。正如42L
有类型long
,你可以发明一个后缀,比如说_S
,来表示short
,所以42_S
是类型short
的编译时常量。清单 25-5 展示了如何定义你自己的文字后缀。
import <iostream>;
short operator "" _S(unsigned long long value)
{
return static_cast<short>(value);
}
void print(short s)
{
std::cout << "short=" << s << '\n';
}
void print(int i)
{
std::cout << "int=" << i << '\n';
}
int main()
{
print(42);
print(42_S);
}
Listing 26-5.User-Defined Literal
当用户定义一个字面值时,它被称为用户定义字面值,或 UDL。文字的名称必须以下划线开头。这将允许 C++ 标准定义不以下划线开头的额外文字,而不用担心干扰您定义的文字。您可以为整数、浮点和字符串类型定义 UDL。
整数运算
当您在表达式中使用signed char
和short
值或对象时,编译器总是将它们转换成类型int
。然后它执行算术或任何你想做的操作。这就是所谓的型 晋升。编译器将 a short
提升为int
。算术运算的结果也是一个int
。
可以在同一个表达式中混用int
和long
。C++ 转换较小的类型来匹配较大的类型,较大的类型就是结果的类型。这被称为类型转换,不同于类型提升。(这种区别可能看起来武断或琐碎,但很重要。下一节将解释其中一个原因。)记住:晋升signed char``short
为int
;将转换为long
。
long big{2147483640};
short small{7};
std::cout << big + small; // promote small to type int; then convert it to long;
// the sum has type long
当比较两个整数时,会发生相同的提升和转换:较小的参数被提升或转换为较大参数的大小。结果总是bool
。
编译器可以将任何数值转换成bool
;它认为这是与任何其他整数转换处于同一级别的转换。
霸王决议
两步类型转换过程可能会困扰你。当你有一组重载的函数时,这就很重要了,编译器必须决定调用哪个函数。编译器尝试的第一件事是找到一个精确的匹配。如果找不到,它会在类型提升后搜索匹配项。只有当失败时,它才搜索允许类型转换的匹配。因此,它认为仅基于类型提升的匹配优于类型转换。清单 26-6 展示了不同之处。
import <iostream>;
// print is overloaded for signed char, short, int and long
void print(signed char value)
{
std::cout << "print(signed char = " << static_cast<int>(value) << ")\n";
}
void print(short value)
{
std::cout << "print(short = " << value << ")\n";
}
void print(int value)
{
std::cout << "print(int = " << value << ")\n";
}
void print(long value)
{
std::cout << "print(long = " << value << ")\n";
}
// guess() is overloaded for bool, int, and long
void guess(bool value)
{
std::cout << "guess(bool = " << value << ")\n";
}
void guess(int value)
{
std::cout << "guess(int = " << value << ")\n";
}
void guess(long value)
{
std::cout << "guess(long = " << value << ")\n";
}
// error() is overloaded for bool and long
void error(bool value)
{
std::cout << "error(bool = " << value << ")\n";
}
void error(long value)
{
std::cout << "error(long = " << value << ")\n";
}
int main()
{
signed char byte{10};
short shrt{20};
int i{30};
long lng{40};
print(byte);
print(shrt);
print(i);
print(lng);
guess(byte);
guess(shrt);
guess(i);
guess(lng);
error(byte); // expected error
error(shrt); // expected error
error(i); // expected error
error(lng);
}
Listing 26-6.Overloading Prefers Type Promotion over Type Conversion
main
的前四行调用print
函数。编译器总能找到精确的匹配,并且很高兴。接下来的四条线叫guess
。当用signed char
和short
参数调用时,编译器将参数提升到int
,并找到与guess(int i)
的精确匹配。
最后四行调用了名副其实的函数error
。问题是编译器将signed char
和short
提升为int
,然后必须将int
转换为long
或bool
。它平等地对待所有转换;因此,它不能决定调用哪个函数,所以它报告一个错误。删除我标有“预期错误”的三行代码,程序就可以正常工作了,或者为error(int value)
添加一个重载,一切都会正常工作。
不明确的重载决策问题是新 C++ 程序员面临的一个难题。对于许多有经验的 C++ 程序员来说,这也是一个很难逾越的障碍。C++ 如何解析重载名称的确切规则是复杂而微妙的,我们将在《探索》 72 中深入探讨。避免在重载函数上耍小聪明,保持简单。大多数重载情况都很简单,但是如果你发现自己正在为类型long
编写一个重载,请确保你也为类型int
编写了一个重载。
了解大整数对一些程序有帮助,但其他程序必须表示更大的数字。下一个探索研究 C++ 如何处理浮点值。
二十七、非常大和非常小的数字
即使是最长的long long
也不能代表真正的大数,比如阿伏伽德罗数(6.02×10 23 )或者极小的数,比如一个电子的质量(9.1×10–31kg)。科学家和工程师使用科学记数法,它由一个尾数(如 6.02 或 9.1)和一个指数(如 23 或–31)组成,相对于基数(10)。
计算机使用类似的表示法来表示非常大和非常小的数字,称为浮点。我知道你们中的许多人一直在急切地等待这一探索,因为你们可能已经厌倦了只使用整数,所以让我们开始吧。
浮点数
计算机使用浮点数表示非常大和非常小的值。通过牺牲精度,你可以获得一个大大扩展的范围。但是,千万不要忘记,范围和精度是有限的。浮点数与数学实数不同,尽管它们通常可以作为实数的有用近似值。
像科学记数法一样,浮点数也有尾数、符号和指数。尾数和指数使用共同的基数或基数。尽管 C++ 中的整数总是以二进制表示,但浮点数可以使用任何基数。二进制是一种流行的基数,但有些计算机使用 16 甚至 10 作为基数。精确的细节总是依赖于实现。换句话说,每个 C++ 实现都使用其本机浮点格式来获得最佳性能。
浮点值通常有多种形式。C++ 提供单精度、双精度和扩展精度,分别称为float
、double
和long double
。不同的是,float
通常比double
精度低,量程小,double
通常比long double
精度低,量程小。作为交换,long double
通常比double
需要更多的内存和计算时间,而double
通常比float
消耗更多的内存和计算时间。另一方面,实现可以自由地对所有三种类型使用相同的表示。
使用double
,除非有理由不使用。当内存非常珍贵,您可以承受失去精度时,请使用float
;当您绝对需要额外的精度或范围,并且可以承受放弃内存和性能时,请使用long double
。
浮点数的一种常见二进制表示是 IEC 60559 标准,它更为人所知的名称是 IEEE 754。很可能,你的桌面系统有实现 IEC 60559 标准的硬件。为了方便起见,下面的讨论只描述 IEC 60559;然而,不要忘记 C++ 允许许多浮点表示。例如,大型机和 DSP 可能使用其他表示法。
一个 IEC 60559 binary32
(C++ float
)占用 32 位,其中 23 位构成尾数,8 位构成指数,剩下一位用于尾数的符号。基数是 2,所以一个 IEC 60559 binary32
的范围大致是 2–127到 2 127 或者 10–38到 10 38 。(我撒谎了。更小的数字是可能的,但是细节与 C++ 没有密切关系。如果你很好奇,在你最喜欢的计算机科学参考书中查找 subnormal 。)
IEC 60559 标准为特殊值保留了一些位模式。特别是,如果指数全是 1 位,尾数全是 0 位,则该值被认为是“无穷大”它不完全是数学上的无穷大,但它尽力假装。例如,将任意有限值与无穷大相加,得到无穷大的答案。正无穷大总是大于任何有限值,负无穷大总是小于有限值。
如果指数全是 1 位,尾数不全是 0 位,则该值被视为非数字,或 NaN 。NaN 有两种类型:安静型和信号型。带有安静 NaN 的算术总是产生 NaN 结果。使用信号 NaN 会导致机器中断。这个中断在你的程序中如何表现取决于实现。一般来说,你应该预料到你的程序会突然终止。请查阅您的编译器文档以了解详细信息。某些没有意义结果的算术运算也会产生 NaN,例如将正无穷大加到负无穷大。
通过调用std::isnan
(在<cmath>
中声明)测试一个值是否为 NaN。存在类似的函数来测试浮点数的无穷大和其他属性。
一个double
(IEC 60559 binary64
)在结构上类似于一个float
,除了它占用 64 位:52 位用于尾数,11 位用于指数,1 位符号位。double
也可以有无穷大和 NaN 值,具有相同的结构表示(即,指数全为 1)。
A long double
甚至比double
还要长。IEC 60559 标准允许至少需要 79 位的扩展双精度格式。许多桌面和工作站系统使用 80 位(尾数 63 位、指数 16 位和 1 位符号位)实现扩展精度浮点数。
浮点文字
任何带小数点或十进制指数的数字文字都代表浮点数。不管语言环境如何,小数点始终是'.'
。指数以字母e
或E
开始,可以带符号。数字文本中不允许有空格,例如:
3.1415926535897
31415926535897e-13
0.000314159265e4
默认情况下,浮点文字的类型为double
。要写一个float
字面值,在数字后面加上字母f
或F
。对于long double
,使用字母l
或L
,如下例所示:
3.141592f
31415926535897E-13l
0.000314159265E+420L
与long int
文字一样,我更喜欢大写的L
,以避免与数字1
混淆。你可以随意使用f
或F
,但我建议你选择一个并坚持使用。为了和L
统一,我更喜欢用F
。
如果浮点文字超出了类型的范围,编译器会告诉你。如果你要求一个比该类型支持的精度更高的值,编译器会自动给你尽可能高的精度。另一种可能是,您请求了一个该类型无法精确表示的值。在这种情况下,编译器会给出下一个更高或更低的值。
例如,你的程序可能有字面量0.2F
,它看起来像一个完美的实数,但是作为一个二进制浮点值,它没有精确的表示。而是大约为 0.0011001100 2 。十进制值和内部值之间的差异会导致意想不到的结果,其中最常见的是当您期望两个数字相等而它们不相等时。读取清单 27-1 预测胜负。
#include <cassert>
int main()
{
float a{0.03F};
float b{10.0F};
float c{0.3F};
assert(a * b == c);
}
Listing 27-1.Floating-Point Numbers Do Not Always Behave As You Expect
你的预测是什么?
实际结果如何?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
问题是 0.03 和 0.3 在二进制中没有精确的表示,所以如果你的浮点格式是二进制(大多数都是),计算机使用的值是真实值的近似值。将 0.03 乘以 10 得到的结果非常接近 0.3,但二进制表示与将 0.3 转换为二进制得到的结果不同。(在 IEC 60559 单精度格式中,0.03 * 10.0 给出 0.01111001100110011001002,0.3 是 0.0111100110011001100110002。这两个数字非常接近,但在第 22 个有效位上有所不同。
一些程序员错误地认为浮点运算因此是“不精确的”相反,浮点运算是精确的,但与实数运算不一样。问题只在于程序员的预期,如果你预期浮点运算要遵循实数运算的规则。如果您意识到编译器将您的十进制文字转换为其他值,并使用这些其他值进行计算,并且如果您了解处理器在使用这些值执行有限精度算术运算时使用的规则,您就可以确切地知道结果会是什么。如果这种详细程度对您的应用程序至关重要,那么您必须花时间来执行这种程度的分析。
然而,我们其他人可以继续假装浮点数和算术几乎是真实的,而不必过多担心差异。只是不要为了完全相等而比较浮点数。(如何比较数字近似相等,不在本书讨论范围之内。请访问网站获取链接和参考资料。)
浮点特征
您可以查询numeric_limits
来揭示浮点类型的大小和限制。您还可以确定该类型是允许无穷大还是 NaN。清单 27-2 显示了一些显示浮点类型信息的代码。
import <cmath>;
import <iostream>;
import <limits>;
import <locale>;
int main()
{
std::cout.imbue(std::locale{""});
std::cout << std::boolalpha;
// Change float to double or long double to learn about those types.
using T = float;
std::cout << "min=" << std::numeric_limits<T>::min() << '\n'
<< "max=" << std::numeric_limits<T>::max() << '\n'
<< "IEC 60559? " << std::numeric_limits<T>::is_iec559 << '\n'
<< "max exponent=" << std::numeric_limits<T>::max_exponent << '\n'
<< "min exponent=" << std::numeric_limits<T>::min_exponent << '\n'
<< "mantissa places=" << std::numeric_limits<T>::digits << '\n'
<< "radix=" << std::numeric_limits<T>::radix << '\n'
<< "has infinity? " << std::numeric_limits<T>::has_infinity << '\n'
<< "has quiet NaN? " << std::numeric_limits<T>::has_quiet_NaN << '\n'
<< "has signaling NaN? " << std::numeric_limits<T>::has_signaling_NaN << '\n';
if (std::numeric_limits<T>::has_infinity)
{
T zero{0};
T one{1};
T inf{std::numeric_limits<T>::infinity()};
if (std::isinf(one/zero))
std::cout << "1.0/0.0 = infinity\n";
if (inf + inf == inf)
std::cout << "infinity + infinity = infinity\n";
}
if (std::numeric_limits<T>::has_quiet_NaN)
{
// There's no guarantee that your environment produces quiet NaNs for
// these illegal arithmetic operations. It's possible that your compiler's
// default is to produce signaling NaNs, or to terminate the program
// in some other way.
T zero{};
T inf{std::numeric_limits<T>::infinity()};
std::cout << "zero/zero = " << zero/zero << '\n';
std::cout << "inf/inf = " << inf/inf << '\n';
}
}
Listing 27-2.Discovering the Attributes of a Floating-Point Type
修改程序,使其打印关于double
的信息。运行它。再次为long double
修改,运行。结果符合你的期望吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
浮点输入输出
读取和写入浮点值取决于区域设置。在传统语言环境中,输入格式与整数或浮点文字相同。在本地语言环境中,您必须根据语言环境的规则编写输入。特别是,小数点分隔符必须是区域设置的分隔符。千位分隔符是可选的,但是如果使用它们,必须使用特定于区域设置的字符和正确的位置。
输出更复杂。
除了字段宽度和填充字符,浮点输出还取决于精度(小数点后的位数)和格式,格式可以是定点(无指数)、科学(有指数)或通用(仅在必要时使用指数)。默认为常规。根据语言环境的不同,数字可能包含千人组的分隔符。
在科学格式和固定格式(使用同名的操纵器指定)中,精度是小数点后的位数。在一般格式中,它是有效数字的最大数量。用precision
成员函数或setprecision
操纵器设置流的精度。默认精度是 6。像往常一样,不带参数的操纵器在<ios>
中声明,所以你可以用<iostream>
免费获得它们,但是setprecision
要求你导入<iomanip>
。在格式说明符中设置小数点后的精度。精度必须在最小宽度和类型字母之间。
double const pi{3.141592653589793238L};
std::cout.precision(12);
std::cout << pi << '\n';
std::cout << std::setprecision(4) << pi << '\n';
std::cout << std::format("{0:.12}\n{0:.4}\n", pi);
在scientific
格式中,指数用小写的'e'
(或者'E'
,如果你使用uppercase
操纵器),后面跟着以 10 为底的指数。指数总是有一个符号(+
或-
)和至少两位数,即使指数为零。尾数写在小数点前一位。精度决定小数点后的位数。
在fixed
格式中,不打印指数。根据需要,在小数点前打印尽可能多的数字。精度决定小数点后的位数。始终打印小数点。
默认格式是通用格式,这意味着在不牺牲信息的情况下很好地打印数字。如果指数小于或等于–4,或者大于精度,则以科学格式打印数字。否则,打印时没有指数。然而,与传统的定点输出不同,小数点后的尾随零被删除。如果删除尾随零后,小数点成为最后一个字符,它也将被删除。
必要时,将值四舍五入以符合分配的精度。
C++ 11 中引入的一种格式是hexfloat
(格式类型'a'
)。该值以十六进制形式打印,这使您可以在以二进制或十六进制表示的系统上找到准确的值。因为字母'e'
是有效的十六进制值,所以指数用字母'p'
或'P'
标记。
指定特定输出格式的最简单方法是使用操纵器:scientific
、fixed
或hexfloat
。像精度一样,格式保持在流的状态,直到您更改它。(只有宽度在输出操作后复位。)不幸的是,一旦设置了格式,就很难恢复到默认的通用格式。为此,您必须使用一个成员函数,而且是一个笨拙的函数,如下所示:
std::cout << std::scientific << large_number << '\n';
std::cout << std::fixed << small_number << '\n';
std::cout.unsetf(std::ios_base::floatfield);
std::cout << number_in_general_format << '\n';
std::cout << std::format("{:e}\n{:f}\n{}\n",
large_number, small_number, number_in_general_format);
在格式说明符中,使用类型'e'
表示指数型或科学型,'f'
表示固定型,'g'
表示常规型,'a'
表示十六进制型。使用大写字母获得大写的'E'
或'P'
作为指数。
完成 表 27-1 ,精确显示每个值如何在经典语言环境中以每种格式打印。为了方便你,我填了第一排。
表 27-1。
浮点输出
|价值
|
精确
|
科学的
|
固定的;不变的
|
六进位浮点
|
一般
|
| --- | --- | --- | --- | --- | --- |
| One hundred and twenty-three thousand four hundred and fifty-six point seven eight nine | six | 1.234568e5
| 123456.789000
| 0x1.e240cap+16
| 123457
|
| 1.23456789 | four | ___________ | _____________ | _______________ | _______________ |
| One hundred and twenty-three million four hundred and fifty-six thousand seven hundred and eighty-nine | Two | ___________ | _____________ | _______________ | _______________ |
| –1234.5678 e9 | five | ___________ | _____________ | _______________ | _______________ |
在你把你的预测填入表格后,写一个程序来测试你的预测,然后运行它,看看你做得有多好。将你的程序与清单 27-3 进行比较。
import <format>;
import <iostream>;
/// Print a floating-point number in three different formats.
/// @param precision the precision to use when printing @p value
/// @param value the floating-point number to print
void print(int precision, float value)
{
std::cout.precision(precision);
std::cout << std::scientific << value << '\t'
<< std::fixed << value << '\t'
<< std::hexfloat << value << '\t';
// Set the format to general.
std::cout.unsetf(std::ios_base::floatfield);
std::cout << value << '\n';
std::cout << std::format("{0:.{1}e}\n{0:.{1}f}\n{0:.{1}a}\n{0:.{1}g}\n",
value, precision);
}
/// Main program.
int main()
{
print(6, 123456.789F);
print(4, 1.23456789F);
print(2, 123456789.F);
print(5, -1234.5678e9F);
}
Listing 27-3.Demonstrating Floating-Point Output
根据浮点表示形式的不同,不同系统的精确值可能会有所不同。例如,大多数系统上的float
不支持九个十进制数字的完全精度,因此打印结果的最低有效数字可能会有些模糊。换句话说,除非你想坐下来做一些严肃的二进制计算,否则你无法准确预测每种情况下的输出。表 27-2 显示了在典型的 IEC 60559 兼容系统上运行时,清单 27-3 的输出。
表 27-2。
打印浮点数的结果
|价值
|
精确
|
科学的
|
固定的;不变的
|
六进位浮点
|
一般
|
| --- | --- | --- | --- | --- | --- |
| One hundred and twenty-three thousand four hundred and fifty-six point seven eight nine | six | 1.234568e+05
| 123456.789062
| 0x1.e240cap+16
| 123457
|
| 1.23456789 | four | 1.2346e+00
| 1.2346
| 0x1.3c0ca4p+0
| 1.235
|
| One hundred and twenty-three million four hundred and fifty-six thousand seven hundred and eighty-nine | Two | 1.23e+08
| 123456792.00
| 0x1.d6f346p+26
| 1.2e+08
|
| –1234.5678 e9 | five | -1.23457e+12
| -1234567823360.00000
| -0x1.1f71fap+40
| -1.2346e+12
|
有些应用程序从来不需要使用浮点数;其他人非常需要它们。例如,科学家和工程师依赖浮点算术和数学函数,必须理解使用这些数字的微妙之处。C++ 拥有计算密集型编程所需的一切。尽管细节超出了本书的范围,感兴趣的读者应该参考一下关于<cmath>
头和它提供的先验函数和其他函数的参考资料。<cfenv>
头包含函数和相关声明,让您调整舍入模式和浮点环境的其他方面。如果在 C++ 参考中找不到关于<cfenv>
的信息,请查阅 C 99 参考中的<fenv.h>
头文件。
接下来的探索将会涉及一个完全不同的主题,解释我在一些程序中使用的奇怪的注释——额外的斜线(///
)和星号(/**
)。
二十八、文件
这次探险和其他的有点不同。它没有涵盖 C++ 标准的一部分,而是研究了一个名为 Doxygen 的第三方工具。请随意跳过这一探索,但要明白这是我解释您有时在代码清单中看到的奇怪注释的地方。
注释
Doxygen 是一个免费的(有费用和许可)工具,它可以读取您的源代码,寻找遵循特定结构的注释,并从注释和代码中提取信息以生成文档。它产生多种格式的输出:HTML、RTF(富文本格式)、LaTeX、UNIX 手册页和 XML。
Java 程序员可能熟悉一种叫做 javadoc 的类似工具。javadoc 工具是 Java 软件开发工具包中的标准,而 Doxygen 与 C++ 标准或任何 C++ 供应商都没有关系。C++ 缺乏结构化文档的标准,所以你可以随心所欲。例如,微软为注释中的 XML 标记定义了自己的约定,如果您打算完全在微软内部工作,这很好。NET 环境。对于其他程序员,我建议使用具有更广泛和可移植用途的工具。最流行的解决方案是 Doxygen,我认为每个 C++ 程序员都应该了解它,即使您决定不使用它。这就是为什么我在书中包括了这个探索。
结构化注释
Doxygen 注意遵循特定格式的注释:
-
单行注释以额外的斜线或感叹号开始:
///
或//!
-
多行注释以一个额外的星号或感叹号开始:
/**
或/*!
此外,Doxygen 认识到一些普遍的注释惯例和修饰。例如,它忽略一行斜线。
//////////////////////////////////////////////////////////////////////////////
多行注释可以以一行星号开始。
/*****************************************************************************
like this
*****************************************************************************/
多行注释中的一行可以以星号开始。
/****************************************************************************
* This is a structured comment for Doxygen. *
****************************************************************************/
在结构化注释中,您可以记录程序中的各种实体:函数、类型、变量等等。
惯例是紧接在声明或定义之前的注释适用于被声明或定义的实体。有时,您希望将注释放在声明之后,例如对变量的单行描述。为此,在注释的开头使用“小于”(<
)符号。
double const c = 299792458.0; ///< speed of light in m/sec
文档标签和降价
Doxygen 有自己的标记语言,利用了标签。标签可以以反斜杠字符(\return
)或“at 符号”(@return
)开始。有些标签有参数,有些没有。除了它自己的标签之外,你还可以使用 HTML 或 Markdown(一种易于读写的类似 wiki 的语法)的子集。最有用的标签、标记和降价如下:
@b 字
将一词标为黑体。您还可以使用 HTML 标记、<b>
短语 </b>
,这在短语包含空格时很有用,或者使用 Markdown,将文本括在星号:* 短语 *中。
@brief 一句话描述
简要描述一个实体。实体有简短而详细的文件。根据您如何配置 Doxygen,简要文档可以是实体完整文档的第一句话,或者您可以要求一个显式的@brief
标记。无论哪种情况,注释的其余部分都是实体的详细文档。
@c 字
将单词视为代码片段,并将其设置为固定间距字体。您也可以使用 HTML 标记、<tt>
短语 </tt>
,或者使用反斜线进行 Markdown、*短语*
。
字
用斜体强调一词。也可以使用 HTML 标签、<em>
短语 </em>
,或者下划线进行 Markdown: _
短语 _
。
@file 文件名
呈现源文件的概述。详细描述可以描述文件的用途、修订历史和其他全局文档。文件名是可选的;没有它,Doxygen 使用文件的真实名称。
@link 实体文本 @endlink
创建一个到命名的实体的超链接,比如一个文件。我在我的@mainpage
上使用@link
来为项目中最重要的文件或唯一的文件创建一个目录。Markdown 提供了多种创建链接的方式,比如 文本 。
@mainpage 标题
为索引或封面开始整个项目的概述。你可以把@mainpage
放在任何源文件中,或者甚至为注释留出一个单独的文件。在小项目中,我将@mainpage
放在与main
函数相同的源文件中,但是在大项目中,我使用一个单独的文件,比如 main.dox 。
@p 姓名
将名称设置为固定间距字体,以区别于函数参数。
@par 标题
开始一个新段落。如果你提供一行标题,它将成为段落标题。空行也可以分隔段落。
@param 名称说明
记录一个名为名为的函数参数。如果您想在函数文档的其他地方引用该参数,请使用@p
名称。
@post 后置条件
记录函数的后置条件。后置条件是一个布尔表达式,当函数返回时,您可以假设它为真(假设所有前提条件都为真)。C++ 缺乏任何强制后置条件的正式机制(除了assert
),所以记录后置条件是至关重要的,尤其是对库作者来说。
@pre 前置条件
记录函数的前提条件。前提条件是一个布尔表达式,在函数被调用之前必须为真,否则不能保证函数正常工作。C++ 缺乏任何强制实施前提条件的正式机制(除了assert
),所以记录前提条件是至关重要的,尤其是对库作者来说。
@return 描述
记录函数返回的内容。
见外部参照
插入对名为 xref 的实体的交叉引用。Doxygen 在结构化注释中查找对其他文档实体的引用。当它找到一个时,它插入一个超链接(或文本交叉引用,取决于输出格式)。然而,有时您必须插入对文档中没有命名的实体的显式引用,因此您可以使用@see
。
您可以通过在名称前加上%
来禁止自动创建超链接。
@&, @@, @, @%, @<
对文字字符(&
、@
、\
、%
或<
)进行转义,以防止被 Doxygen 或 HTML 解释。
Doxygen 非常灵活,您有很多方法使用原生 Doxygen 标签、HTML 或 Markdown 来格式化您的注释。这本书的网站有到 Doxygen 主页的链接,在那里你可以找到更多关于该工具的信息并下载该软件。大多数 Linux 用户已经有了 Doxygen 其他用户可以为他们喜欢的平台下载 Doxygen。
清单 28-1 展示了使用 Doxygen 的许多方法中的一些。
/** @file
* @brief Tests strings to see whether they are palindromes.
*
* Reads lines from the input, strip non-letters, and checks whether
* the result is a palindrome. Ignores case differences when checking.
* Echoes palindromes to the standard output.
*/
/** @mainpage Palindromes
* Tests input strings to see whether they are palindromes.
*
* A _palindrome_ is a string that reads the same forward and backward.
* To test for palindromes, this program needs to strip punctuation and
* other non-essential characters from the string, and compare letters without
* regard to case differences.
*
* This program reads lines of text from the standard input and echoes
* to the standard output those lines that are palindromes.
*
* Source file: list2801.cpp
*
* @date 27-March-2020
* @author Ray Lischner
* @version 3.0
*/
import <algorithm>;
import <iostream>;
import <ranges>;
import <locale>;
import <ranges>;
import <string>;
import <string_view>;
/** @brief Tests for non-letter.
*
* Tests the character @p ch in the global locale.
* @param ch the character to test
* @return true if @p ch is not a letter
*/
bool non_letter(char ch)
{
return not std::isalnum(ch, std::locale{});
}
/** @brief Converts to lowercase.
*
* All conversions use the global locale.
*
* @param ch the character to test
* @return the character converted to lowercase
*/
char lowercase(char ch)
{
return std::tolower(ch, std::locale{});
}
/** @brief Compares two characters without regard to case.
*
* @param a one character to compare
* @param b the other character to compare
* @return `true` if the characters are the same in lowercase,
* `false` if they are different.
*/
bool is_same_char(char a, char b)
{
return lowercase(a) == lowercase(b);
}
/** @brief Determines whether @p str is a palindrome.
*
* Only letter characters are tested. Spaces and punctuation don't count.
* Empty strings are not palindromes because that's just too easy.
* @param str the string to test
* @return `true` if @p str is the same forward and backward and
* `not str.empty()`
*/
bool is_palindrome(std::string_view str)
{
auto filtered_str{ str | std::views::filter(lowercase) };
return std::ranges::equal(filtered_str, filtered_str|std::views::reverse,
is_same_char);
}
/** @brief Main program.
* Sets the global locale to the user's native locale.
* Then imbues the I/O streams with the native locale.
*/
int main()
{
std::locale::global(std::locale{""});
std::cin.imbue(std::locale{});
std::cout.imbue(std::locale{});
for (std::string line{}; std::getline(std::cin, line); /*empty*/)
if (is_palindrome(line))
std::cout << line << '\n';
}
Listing 28-1.Documenting Your Code with Doxygen
图 28-1 显示了网页浏览器中的主页。
图 28-1。
回文文档的主页
使用 Doxygen
Doxygen 没有采用大量的命令行参数,而是使用一个配置文件,通常命名为Doxyfile
,您可以将所有有趣的信息放入其中。配置文件中的信息包括项目的名称、要检查注释的文件、要生成的输出格式以及可以用来调整输出的各种选项。
由于选项太多,Doxygen 附带了一个向导doxywizard
,来帮助生成一个合适的配置文件,或者您可以使用-g
开关运行命令行doxygen
实用程序,来生成一个默认的配置文件,其中有很多注释来帮助您理解如何定制它。
一旦配置了 Doxygen,运行程序就变得简单了。简单地运行doxygen
,它就走了。Doxygen 在解析 C++ 方面做得很好,c++ 是一种复杂且难以解析的语言,但它有时会混淆。注意错误消息,看看源文件是否有问题。
配置文件规定了输出的位置。通常,每种输出格式都位于自己的子目录中。例如,默认配置文件将 HTML 输出存储在html
目录中。在您喜欢的浏览器中打开html/index.html
文件,查看结果。
在您的系统上下载并安装 Doxygen。
将 Doxygen 注释添加到您的程序中。配置并运行 Doxygen。
未来的程序将继续零星地使用 Doxygen 注释,当我认为这些注释有助于你理解程序是做什么的时候。不过,总的来说,我尽量避免在书中提到它们,因为文本通常会足够好地解释事情,我不想浪费任何空间。然而,本书附带的程序有更完整的 Doxygen 注释。
二十九、项目 1:身体质量指数
项目时间到了!身体质量指数(身体质量指数)是一些卫生保健专业人员用来确定一个人是否超重,如果是,超重多少的测量方法。为了计算身体质量指数,你需要一个人的体重(公斤)和身高(米)。身体质量指数就是体重/身高 2 ,转换成无单位值。
你的任务是编写一个程序,读取记录,打印记录,并计算一些统计数据。这个程序应该从要求一个阈值身体质量指数开始。仅打印身体质量指数大于或等于阈值的记录。每个记录需要包含一个人的姓名(可以包含空格)、体重(以千克为单位)、身高(以厘米为单位,而不是米)和性别('M'
或'F'
)。让用户以大写或小写输入性别。要求用户以厘米为单位输入高度,这样您就可以使用整数来计算身体质量指数。你必须调整公式,把米改为厘米。
阅读每个人的记录后,立即打印其身体质量指数。在收集了每个人的信息后,根据数据打印两张表——一张是男性的,一张是女性的。在身体质量指数评级后使用星号来标记数量达到或超过阈值的记录。在每张表格后,打印平均值和身体质量指数中值。(中位数是一半身体质量指数值小于中位数,一半大于中位数的值。如果用户输入偶数条记录,则取中间两个值的平均值。)将单个身体质量指数值计算为整数。以浮点数形式计算身体质量指数值的平均值和中值,并打印小数点后有一位的平均值。
清单 29-1 显示了一个示例用户会话。用户输入以粗体显示。
$ bmi
This program computes Bogus Metabolic Indicator (BMI) values.
Enter threshold BMI: 25
Enter name, height (in cm), and weight (in kg) for each person:
Name 1: Ray Lischner
Height (cm): 180
Weight (kg): 90
Sex (m or f): m
BMI = 27
Name 2: A. Nony Mouse
Height (cm): 120
Weight (kg): 42
Sex (m or f): F
BMI = 29
Name 3: Mick E. Mouse
Height (cm): 30
Weight (kg): 2
Sex (m or f): M
BMI = 22
Name 4: A. Nony Lischner
Height (cm): 150
Weight (kg): 55
Sex (m or f): m
BMI = 24
Name 5: No One
Height (cm): 250
Weight (kg): 130
Sex (m or f): f
BMI = 20
Name 6: ^Z
Male data
Ht(cm) Wt(kg) Sex BMI Name
180 90 M 27* Ray Lischner
30 2 M 22 Mick E. Mouse
150 55 M 24 A. Nony Lischner
Mean BMI = 24.3
Median BMI = 24
Female data
Ht(cm) Wt(kg) Sex BMI Name
120 42 F 29* A. Nony Mouse
250 130 F 20 No One
Mean BMI = 24.5
Median BMI = 24.5
Listing 29-1.Sample User Session with the BMI Program
暗示
如果你需要的话,这里有一些提示:
-
在单独的向量中记录数据,例如,
heights
、weights
、sexes
、names
、bmis
。 -
对所有输入和输出使用本地语言环境。
-
将程序分成函数,例如,
compute_bmi
根据体重和身高计算身体质量指数。 -
你可以只使用我们到目前为止介绍的技术来编写这个程序,但是如果你知道其他的技术,请随意使用它们。下一组探索将呈现语言特性,这将极大地方便编写这类程序。
-
我的解决方案的完整源代码可以在本书附带的其他文件中找到,但是在您自己编写程序之前不要偷看。
三十、自定义类型
C++ 的关键设计目标之一是,您应该能够定义外观和行为都与内置类型相似的全新类型。需要三态逻辑吗?自己写tribool
类型。需要任意精度的算术?自己写bigint
类型。更好的是,让别人来写,你用普通int
的方式使用bigint
。本文介绍了一些允许您定义自定义类型的语言特性。随后的探索对这些主题进行了更深入的研究。
定义新类型
让我们考虑一个场景,其中您想要定义一个类型rational
,来表示有理数(分数)。有理数有一个分子和一个分母,都是整数。理想情况下,您将能够以与内置数值类型相同的方式对有理数进行加、减、乘、除操作。您还应该能够在同一个表达式中混合有理数和其他数字类型。(我们的rational
类型不同于std::ratio
类型,它代表一个编译时常量;我们的rational
类型可以在运行时改变值。)
I/O 流应该能够以某种合理的方式读写有理数。输入操作符应该接受输出操作符产生的任何东西作为有效输入。I/O 操作符应该留意流的标志和相关设置,例如字段宽度和填充字符,这样您就可以像在 Exploration 8 中处理整数一样,格式化整齐对齐的有理数列。
您应该能够将任何数值赋给一个rational
变量,并将一个rational
值转换成任何内置的数值类型。自然地,将有理数转换为整数变量会导致截断为整数。有人可能认为转换应该是自动的,类似于从浮点到整数的转换。一个相反的论点是,丢弃信息的自动转换在最初的 C 语言设计中是一个错误,不应该被复制。相反,丢弃信息的转换应该清晰明了。我更喜欢后一种方法。
这一次要处理的事情很多,所以让我们慢慢开始。
第一步是决定如何存储一个有理数。你必须存储一个分子和一个分母,都是整数。负数呢?选择一个约定,比如分子得到整个值的符号,分母总是正的。清单 30-1 显示了一个基本的rational
类型定义。
/// Represent a rational number.
struct rational
{
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
Listing 30-1.Defining a Custom rational Type
定义从关键字struct
开始。c 程序员认为这是一个结构定义——但是等等,还有更多的内容。
rational
类型的内容看起来像是名为numerator
和denominator
的变量的定义,但是它们的工作方式略有不同。记住清单 30-1 显示了一个类型定义。换句话说,编译器记得rational
命名了一个类型,但是它没有为一个对象、numerator
或denominator
分配任何内存。用 C++ 的说法,numerator
和denominator
被称为数据成员;其他一些语言称之为实例变量或字段。
注意右大括号后面的分号。类型定义不同于复合语句。如果你忘记了分号,编译器会提醒你,有时会很粗鲁地提醒你,同时指出分号所属行之外的行号。
当定义一个类型为rational
的对象时,该对象存储了numerator
和denominator
成员。使用点(.
)操作符来访问成员(在本书中你一直在这么做),如清单 30-2 所示。
import <iostream>;
/// Represent a rational number.
struct rational
{
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{};
pi.numerator = 355;
pi.denominator = 113;
std::cout << "pi is approximately " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-2.Using a Custom Type and Its Members
那不是非常令人兴奋的,是吗?类型只是坐在那里,毫无生气。你知道标准库中很多类型都有成员函数,比如std::ostream
的width
成员函数,允许你写std::cout.width(4)
。下一节将展示如何编写自己的成员函数。
成员函数
让我们给rational
添加一个成员函数,用分子和分母的最大公约数来减少它们。清单 30-3 显示了带有reduce()
成员函数的示例程序。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Reduce the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{};
pi.numerator = 1420;
pi.denominator = 452;
pi.reduce();
std::cout << "pi is approximately " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-3.Adding the reduce Member Function
注意reduce()
成员函数看起来就像一个普通的函数,除了它的定义出现在rational
类型定义中。它调用gcd
(最大公约数)函数,该函数在<numeric>
中声明。还要注意reduce()
如何引用rational
的数据成员。当调用reduce()
成员函数时,必须提供一个对象作为点(.
)操作符的左操作数(如清单 30-3 中的pi
)。当reduce()
函数引用一个数据成员时,该数据成员取自左边的操作数。于是,numerator = numerator / div
就有了pi.numerator = pi.numerator / div
的效果。
成员函数也可以调用在同一类型中定义的其他成员函数。自己试试:添加 assign()成员函数,该函数以一个numerator
和denominator
为两个参数,赋给各自的数据成员,并调用reduce()
。这为rational
的用户节省了额外的步骤(以及忽略对reduce()
调用的可能错误)。设返回类型为void
。在这里写你的成员函数:
清单 30-4 展示了整个程序,我的assign
成员函数用粗体显示。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Assigns a numerator and a denominator, then reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{};
pi.assign(1420, 452);
std::cout << "pi is approximately " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-4.Adding the assign Member Function
注意现在的main
程序是多么简单。隐藏细节,比如reduce()
,有助于保持代码的整洁、可读性和可维护性。
注意另一个微妙的细节:assign()
的定义在reduce()
之前,尽管它调用了reduce()
。我们需要对这个规则做一个小的调整,编译器必须至少看到一个名字的声明,然后你才能使用这个名字。struct
的成员可以引用其他成员,而不考虑类型中声明的顺序。在所有其他情况下,您必须在使用前提供声明。
能够在一个步骤中分配分子和分母是对rational
类型的一个很好的补充,但更重要的是能够初始化一个rational
对象。回想一下 Exploration 5 中我对确保所有对象都被正确初始化的告诫。下一节演示如何给rational
添加对初始化器的支持。
构造器
如果能够初始化一个带有分子和分母的rational
对象,并自动对它们进行适当的缩减,这不是很好吗?你可以通过编写一个特殊的成员函数来实现,这个函数看起来和行为有点像assign
,除了名字和类型的名字一样(rational
),而且这个函数没有返回类型或者返回值。清单 30-5 展示了如何编写这个特殊的成员函数。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Constructs a rational object, given a numerator and a denominator.
/// Always reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
/// Assigns a numerator and a denominator, then reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
int main()
{
rational pi{1420, 452};
std::cout << "pi is about " << pi.numerator << "/" << pi.denominator << '\n';
}
Listing 30-5.Adding the Ability to Initialize a rational Object
注意pi
对象的定义。变量在其初始化器中接受参数,这两个参数以与函数参数相同的方式传递给特殊的初始化器函数。这个特殊的成员函数被称为构造器。
构造器看起来很像普通的函数,除了它没有返回类型。此外,您不能选择名称,但必须使用类型名称。还有一行是以冒号开头的。这段额外的代码初始化数据成员的方式与初始化变量的方式相同。在所有的数据成员被初始化之后,构造器的主体以与任何成员函数主体相同的方式运行。
初始化列表是可选的。没有它,数据成员就没有初始化——这是一件坏事,所以不要这样做。
修改 rational 类型,使其接受负分母。如果分母为负,将其改为正,同时改变分子的符号。因此,rational{-710, -227}
将具有与rational{710, 227}
相同的值。
您可以选择在许多地方中的任何一个进行修改。良好的软件设计实践表明,变更应该恰好发生在一个点上,所有其他功能都应该调用那个点。因此,我建议修改reduce()
,如清单 30-6 所示。
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
if (denominator < 0)
{
denominator = -denominator;
numerator = -numerator;
}
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
Listing 30-6.Modifying the reduce Member Function to Accept a Negative Denominator
重载构造器
可以像重载普通函数一样重载构造器。所有重载的构造器都有相同的名称(即类型的名称),并且它们的参数数量或类型必须不同。例如,您可以添加一个接受单个整数参数的构造器,隐式使用 1 作为分母。
编写重载构造器有两种方法可供选择。你可以让一个构造器调用另一个,或者让构造器初始化所有成员,就像你写的第一个构造器一样。若要调用另一个构造器,请使用初始值设定项列表中的类型名。
rational(int num) : rational{num, 1} {}
或者直接初始化每个成员。
rational(int num)
: numerator{num}, denominator{1}
{}
当您希望多个构造器共享一个共同的行为时,将工作委托给一个共同的构造器非常有意义。例如,你可以有一个单独的构造器调用reduce()
,其他所有的构造器都可以调用那个构造器,从而确保无论你如何构造rational
对象,你都知道它已经被减少了。
另一方面,当分母为 1 时,不需要调用reduce()
,所以您可能更喜欢第二种形式,直接初始化数据成员。选择权在你。
调用另一个构造器的构造器被称为委托构造器,因为它将其工作委托给另一个构造器。
我相信你能看到rational
型目前状态的很多不足。它有几个你可能也错过了。挺住;下一次探索开始改进类型。例如,您可能想通过比较两个rational
对象来测试您的修改,看看它们是否相等。然而,要做到这一点,您必须编写一个定制的==
操作符,这是下一个探索的主题。
三十一、重载运算符
这一探索继续了对编写自定义类型的研究。使自定义类型与内置类型无缝运行的一个重要方面是确保自定义类型支持所有预期的运算符——算术类型必须支持算术运算符,可读和可写类型必须支持 I/O 运算符,等等。幸运的是,C++ 允许你重载操作符,就像重载函数一样。
比较有理数
在之前的探索中,你开始写一个rational
类型。在对它进行修改之后,一个重要的步骤是测试修改后的类型,内部测试的一个重要方面是等号(==
)操作符。C++ 允许您为几乎每个操作符定义一个自定义实现,前提是至少有一个操作数具有自定义类型。换句话说,您不能重新定义整数除法来产生一个rational
结果,但是您可以定义一个整数除以一个rational
数,反之亦然。
要实现一个定制的操作符,编写一个普通的函数,但是对于函数名,使用operator
关键字,后跟操作符符号,如清单 31-1 所示。
#include <cassert>
import <iostream>;
import <numeric>;
/// Represents a rational number.
struct rational
{
/// Constructs a rational object, given a numerator and a denominator.
/// Always reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
/// Assigns a numerator and a denominator, then reduces to normal form.
/// @param num numerator
/// @param den denominator
/// @pre denominator > 0
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduces the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
if (denominator < 0)
{
denominator = -denominator;
numerator = -numerator;
}
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator; ///< numerator gets the sign of the rational value
int denominator; ///< denominator is always positive
};
/// Compares two rational numbers for equality.
/// @pre @p a and @p b are reduced to normal form
bool operator==(rational const& a, rational const& b)
{
return a.numerator == b.numerator and a.denominator == b.denominator;
}
/// Compare two rational numbers for inequality.
/// @pre @p a and @p b are reduced to normal form
bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
int main()
{
rational pi1{355, 113};
rational pi2{1420, 452};
if (pi1 == pi2)
std::cout << "success\n";
else
std::cout << "failure\n";
}
Listing 31-1.Overloading the Equality Operator
减少所有rational
数字的好处之一是,这使得比较更容易。构造器没有检查3/3
是否与6/6
相同,而是将两个数字都简化为1/1
,所以这只是比较分子和分母的问题。另一个诀窍是用==
来定义!=
。为自己做额外的工作是没有意义的,所以将比较rational
对象的实际逻辑限制在一个函数中,并从另一个函数中调用它。如果您担心调用额外的一层函数的性能开销,可以使用关键字inline
,如清单 31-2 所示。
/// Compares two rational numbers for equality.
/// @pre @p a and @p b are reduced to normal form
bool operator==(rational const& a, rational const& b)
{
return a.numerator == b.numerator and a.denominator == b.denominator;
}
/// Compares two rational numbers for inequality.
/// @pre @p a and @p b are reduced to normal form
inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
Listing 31-2.Using inline for Trivial Functions
关键字inline
是对编译器的一个暗示,你希望函数在调用点被扩展。如果编译器决定听从您的意愿,那么结果程序中不会有任何名为operator!=
的可识别函数。相反,每一个使用带有rational
对象的!=
操作符的地方,函数体都在那里被扩展,导致对operator==
的调用。
要实现<
操作符,您需要一个公分母。一旦实现了operator<
,就可以根据<
实现所有其他的关系操作符。您可以选择任意一个关系运算符(<
、>
、<=
、>=
)作为基本运算符,并根据基本运算符实现其他运算符。惯例是从<
开始。清单 31-3 演示了<
和<=
。
/// Compares two rational numbers for less-than.
bool operator<(rational const& a, rational const& b)
{
return a.numerator * b.denominator < b.numerator * a.denominator;
}
/// Compares two rational numbers for less-than-or-equal.
inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
Listing 31-3.Implementing the < Operator for rational
执行>和> =根据<。
将您的运算符与清单 31-4 进行比较。
/// Compares two rational numbers for greater-than.
inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
/// Compares two rational numbers for greater-than-or-equal.
inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
Listing 31-4.Implementing the > and >= Operators in Terms of <
然后编写一个测试程序。为了帮助您编写测试,下载test.cpp
文件并将import test
添加到您的程序中。根据需要多次调用TEST()
函数,传递一个布尔表达式作为唯一参数。如果参数为真,则测试通过。如果参数为假,则测试失败,并且TEST
函数打印一条合适的消息。因此,您可以编写测试,如下所示:
TEST(rational{2, 2} == rational{5, 5});
TEST(rational{6,3} > rational{10, 6});
全大写的名字TEST
,告诉你TEST
不同于一个普通的函数。特别是,如果测试失败,测试文本将作为失败消息的一部分打印出来。TEST
函数如何工作超出了本书的范围,但是有它在身边还是很有用的;您将在未来的测试工具中使用它。将你的测试程序与清单 31-5 进行比较。
#include <cassert>
import <iostream>;
import <numeric>;
import test;
... struct rational omitted for brevity ...
int main()
{
rational a{60, 5};
rational b{12, 1};
rational c{-24, -2};
TEST(a == b);
TEST(a >= b);
TEST(a <= b);
TEST(b <= a);
TEST(b >= a);
TEST(b == c);
TEST(b >= c);
TEST(b <= c);
TEST(a == c);
TEST(a >= c);
TEST(a <= c);
rational d{109, 10};
TEST(d < a);
TEST(d <= a);
TEST(d != a);
TEST(a > d);
TEST(a >= d);
TEST(a != d);
rational e{241, 20};
TEST(e > a);
TEST(e >= a);
TEST(e != a);
TEST(a < e);
TEST(a <= e);
TEST(a != e);
}
Listing 31-5.Testing the rational Comparison Operators
算术运算符
比较没问题,但是算术运算符要有趣得多。您可以重载任何或所有算术运算符。二元运算符有两个参数,一元运算符有一个参数。你可以选择任何有意义的返回类型。清单 31-6 显示了二元加法运算符和一元否定运算符。
rational operator+(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator + rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
rational operator-(rational const& r)
{
return rational{-r.numerator, r.denominator};
}
Listing 31-6.Addition Operator for the rational Type
编写其他算术运算符:-、*和/ 。暂时忽略被零除的问题。将你的功能与我的功能进行比较,我的功能在清单 31-7 中列出。
rational operator-(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator - rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
rational operator*(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.numerator, lhs.denominator * rhs.denominator};
}
rational operator/(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator, lhs.denominator * rhs.numerator};
}
Listing 31-7.Arithmetic Operators for the rational Type
用rational
数字做加法、减法等等都没问题,但是更有趣的是混合类型的问题。比如3 * rational(1, 3)
的值是多少?试试看。收集带有所有操作符的rational
类型的定义,并编写一个main()
函数来计算该表达式并将其存储在某个地方。为结果变量选择一种对你有意义的类型,然后决定如何最好地将该值打印到std:
:cout
。
你希望表达式编译时没有错误吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
表达式的结果类型是什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
你希望结果是什么样的?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
解释你的观察结果。
原来,rational
的单参数构造器告诉编译器,它可以在任何需要的时候从一个int
构造一个rational
。这是自动完成的,所以编译器会看到整数3
以及一个int
和一个rational
对象的乘积。它知道两个rational
之间的operator*
,并且它知道它不能使用带有rational
操作数的内置*
操作符。因此,编译器决定它的最佳响应是将int
转换为rational
(通过调用rational{3}
),然后它可以应用将两个rational
对象相乘的自定义operator*
,产生一个rational
结果,即rational{1, 1}
。它会代表您自动完成所有这些工作。清单 31-8 展示了编写测试程序的一种方法。
#include <cassert>
import <iostream>;
import <numeric>;
... struct rational omitted for brevity ...
int main()
{
rational result{3 * rational{1, 3}};
std::cout << result.numerator << '/' << result.denominator << '\n';
}
Listing 31-8.Test Program for Multiplying an Integer and a Rational Number
能够从一个int
自动构造一个rational
对象是非常方便的。您可以轻松地编写对整数和有理数执行运算的代码,而无需一直关注类型转换。当混合整数和浮点数时,您会发现同样的便利。例如,您可以编写1+2.0
,而不必执行类型转换:static_cast<double>(1)+2.0
。
另一方面,所有这些便利可能都太方便了。尝试编译下面的代码样本,看看你的编译器报告了什么:
int a(3.14); // which one is okay,
int b{3.14}; // and which is an error?
我总是用花括号来初始化变量,但是只要你至少提供一个参数,圆括号也可以。你也为安全付出了代价。当您使用括号进行初始化时,编译器允许丢失信息的转换,如从浮点到整数的转换,但当您使用花括号时,它会报告错误。
这种差异对于rational
型来说至关重要。使用圆括号中的浮点数初始化有理数会将该数截断为整数,并使用构造器的单参数形式。这根本不是你想要的。相反,初始化rational{3.14}
应该产生与rational{314, 100}
相同的结果。
编写从浮点到分数的高质量转换超出了本书的范围。相反,让我们选择一个合理的 10 的幂作为分母。假设我们选择 100,000,那么rational{3.14159}
将被视为rational{static_cast<int>(3.14159 * 100000), 100000}
。写一个浮点数的构造器。我建议使用委托构造器;也就是说,编写浮点构造器,以便它调用另一个构造器。
将你的结果与我在清单 31-9 中的结果进行比较。一个更好的解决方案是使用numeric_limits
来确定double
可以支持的精度的十进制位数,并试图保持尽可能多的精度。一个更好的解决方案是使用浮点实现的基数,而不是使用基数 10。
struct rational
{
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
rational(double r)
: rational{static_cast<int>(r * 100000), 100000}
{}
... omitted for brevity ...
int numerator;
int denominator;
};
Listing 31-9.Constructing a Rational Number from a Floating-Point Argument
如果您想为特定的参数类型优化特定的函数,您也可以通过利用普通的函数重载来实现。不过,你最好确保这项额外的工作是值得的。请记住int
操作数可以是右操作数或左操作数,所以您必须重载这两种形式的函数,如列表 31-10 所示。
rational operator*(rational const& rat, int mult)
{
return rational{rat.numerator * mult, rat.denominator};
}
inline rational operator*(int mult, rational const& rat)
{
return rat * mult;
}
Listing 31-10.Optimizing Operators for a Specific Operand Type
在这样一个简单的例子中,为了避免一点额外的运算而增加麻烦是不值得的。然而,在更复杂的情况下,例如除法,您可能需要编写这样的代码。
数学函数
C++ 标准库提供了许多数学函数,比如计算绝对值的std::abs
(你已经猜到了)。如你所见,一些数学函数,如gcd
,在<numeric>
中。大部分都在<cmath>
里,因为这是从 C 语言继承来的,所以必须用#include
代替import
。C++ 标准禁止重载这些标准函数来操作自定义类型,但是您仍然可以编写执行类似操作的函数。在 Exploration 71 中,您将了解名称空间,这将使您能够使用真正的函数名。每当编写自定义数值类型时,都应该考虑应该提供哪些数学函数。在这种情况下,绝对值非常有意义。写一个处理有理数的绝对值函数。称之为 absval
。
您的absval
函数应该通过值接受一个rational
参数,并返回一个rational
结果。与我编写的算术运算符一样,您可以选择对参数使用引用调用。如果是这样,请确保将引用声明为const
。清单 31-11 展示了我对absval
的实现。
rational absval(rational const& r)
{
return rational{std::abs(r.numerator), r.denominator};
}
Listing 31-11.Computing the Absolute Value of a Rational Number
那很简单。其他用于计算平方根的数学函数,比如sqrt
,又是如何呢?对于浮点参数,大多数其他函数都是重载的。如果编译器知道如何自动将有理数转换为浮点数,您可以简单地将一个rational
参数传递给任何现有的浮点函数,而无需做进一步的工作。那么,您应该使用哪种浮点类型呢?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
这个问题没有简单的答案。合理的首选可能是double
,这是“默认”的浮点类型(例如,浮点文字具有类型double
)。另一方面,如果有人真的想要long double
提供的额外精度呢?或者那个人不需要太多精度,更喜欢用float
怎么办?
解决方案是放弃自动转换为浮点类型的可能性,而是提供三个函数来显式计算有理数的浮点值。写 as_float,as_double,as_long_double 。这些成员函数中的每一个都计算并返回有理数的浮点近似值。函数名标识了返回类型。您必须使用static_cast
将分子和分母转换为所需的浮点类型,正如您在 Exploration 26 中学到的那样。清单 31-12 展示了我是如何编写这些函数的,其中有一个示例程序演示了它们的用法。
#include <cassert>
import <iostream>;
import <numeric>;
struct rational
{
float as_float()
{
return static_cast<float>(numerator) / denominator;
}
double as_double()
{
return numerator / static_cast<double>(denominator);
}
long double as_long_double()
{
return static_cast<long double>(numerator) /
static_cast<long double>(denominator);
}
... omitted for brevity ...
};
int main()
{
rational pi{355, 113};
rational bmi{90*100*100, 180*180}; // Bogus-metabolic indicator of 90kg, 180cm
double circumference{0}, radius{10};
circumference = 2 * pi.as_double() * radius;
std::cout << "circumference of circle with radius " << radius << " is about "
<< circumference << '\n';
std::cout << "bmi = " << bmi.as_float() << '\n';
}
Listing 31-12.Converting to Floating-Point Types
如您所见,如果/
(或任何其他算术或比较运算符)的一个参数是浮点的,则另一个操作数被转换为匹配。您可以转换两个操作数,或者只转换其中一个。挑选最适合自己的风格,坚持下去。
还有一项任务会使编写测试程序更容易:重载 I/O 操作符。这是下一个探索的主题。
三十二、自定义 I/O 运算符
能直接读写有理数不是很好吗,比如std::cout << rational{355, 113}
?事实上,C++ 拥有你所需要的一切,尽管这项工作比想象中的要复杂一些。本文介绍了实现这一目标所需的一些部分。
输入运算符
I/O 操作符就像 C++ 中的其他操作符一样,你可以像重载其他操作符一样重载它们。输入操作符,也称为提取器(因为它从流中提取数据),将std::istream&
作为第一个参数。它必须是非const
引用,因为函数会修改流对象。第二个参数也必须是非const
引用,因为您将在那里存储输入值。按照惯例,返回类型是std::istream&
,返回值是第一个参数。这使您可以在单个表达式中组合多个输入操作。(返回清单 17-3 查看示例。)
函数体必须完成读取输入流、解析输入以及决定如何解释输入的工作。正确的错误处理是困难的,但是基本的很容易。每个流都有一个跟踪错误的状态掩码。表 32-1 列出了可用的状态标志(在<ios>
中声明)。
表 32-1。
输入输出状态标志
|旗
|
描述
|
| --- | --- |
| badbit
| 不可恢复的错误 |
| eofbit
| 文件结尾 |
| failbit
| 无效的输入或输出 |
| goodbit
| 没有错误 |
如果输入无效,输入函数将failbit
设置为流的错误状态。当调用者测试流是否正常时,它测试错误状态。如果failbit
被设置,检查失败。(如果出现不可恢复的错误,如硬件故障,测试也会失败,但这与当前主题无关。)
现在您必须决定有理数的格式。这种格式应该足够灵活,便于人们读写,但又足够简单,便于函数快速读取和解析。输入格式必须能够读取输出格式,也可能能够读取其他格式。
我们把格式定义为一个整数,一个斜杠(/
),再一个整数。空白可以出现在这些元素的前面或后面,除非在输入流中禁用了空白标志。如果输入包含后面没有斜杠的整数,则该整数成为结果值(即隐式分母为 1)。输入操作符必须“不读”这个字符,这对程序的其余部分可能很重要。unget()
成员函数正是这样做的。整数的输入操作符也会做同样的事情:读取尽可能多的字符,直到读取一个不属于整数的字符,然后是最后一个字符unget
。
将所有这些片段放在一起需要一点小心,但并不那么困难。清单 32-1 给出了输入操作符。将该操作符添加到您在 Exploration 31 中编写的rational
类型的其余部分。
... copy the rational class from Exploration 31
std::istream& operator>>(std::istream& in, rational& rat)
{
int n{0}, d{0};
char sep{'\0'};
if (not (in >> n >> sep))
// Error reading the numerator or the separator character.
in.setstate(std::cin.failbit);
else if (sep != '/')
{
// Read numerator successfully, but it is not followed by /.
// Push sep back into the input stream, so the next input operation
// will read it.
in.unget();
rat.assign(n, 1);
}
else if (in >> d)
// Successfully read numerator, separator, and denominator.
rat.assign(n, d);
else
// Error reading denominator.
in.setstate(std::cin.failbit);
return in;
}
Listing 32-1.Input Operator
请注意,直到函数成功地从流中读取分子和分母,才会修改rat
。目标是确保如果流进入错误状态,函数不会改变rat
。
输入流自动处理空白。默认情况下,输入流在每个输入操作中跳过前导空白,这意味着rational
输入操作符跳过分子、斜杠分隔符和分母之前的空白。如果程序关闭了ws
标志,输入流不会跳过空白,并且所有三个部分必须是连续的。
输出运算符
编写输出操作符,或插入器(这样命名是因为它将文本插入到输出流中),由于过多的格式标志,有许多障碍。您希望留意所需的字段宽度和对齐方式,并且必须根据需要插入填充字符。像任何其他输出操作符一样,您希望重置字段宽度,但不更改任何其他格式设置。
编写复杂输出操作符的一种方法是使用一个临时输出流,将其文本存储在一个string
中。在<sstream>
模块中声明了std::ostringstream
类型。像使用任何其他输出流一样使用ostringstream
,比如cout
。当你完成时,str()
成员函数返回完成的string
。
要为一个rational
数字编写输出操作符,创建一个ostringstream
,然后编写分子、分隔符和分母。接下来,将结果字符串写入实际的输出流。让流本身在写入字符串时处理宽度、对齐和填充问题。如果您将分子、斜杠和分母直接写入输出流,宽度将只应用于分子,对齐将是错误的。类似于输入操作符,第一个参数的类型是std::ostream&
,这也是返回类型。返回值是第一个参数。第二个参数可以使用 call-by-value,或者您可以传递一个对const
的引用,如清单 32-2 所示。将这段代码添加到清单 32-1 和您正在定义的rational
类型的其余部分。
std::ostream& operator<<(std::ostream& out, rational const& rat)
{
std::ostringstream tmp{};
tmp << rat.numerator;
if (rat.denominator != 1)
tmp << '/' << rat.denominator;
out << tmp.str();
return out;
}
Listing 32-2.Output Operator
错误状态
下一步是编写测试程序。理想情况下,测试程序应该能够在遇到无效输入错误时继续运行。所以现在是一个很好的时机来仔细看看一个 I/O 流是如何跟踪错误的。
正如您在前面的探索中了解到的,每个流都有一个错误标志掩码(见表 32-1 )。您可以测试、设置或清除这些标志。然而,测试标志有点不寻常,所以要注意。
本书中大多数程序测试流错误条件的方法是使用流本身或输入操作作为条件。如您所知,输入操作符函数返回流,因此这两种方法是等效的。如果设置了failbit
或badbit
,流通过返回其fail()
函数的反函数转换为bool
结果,该函数返回true
。
在输入循环的正常过程中,程序一直前进,直到输入流用完为止。当流到达输入流的末尾时,流设置eofbit
。流的状态仍然是好的,因为fail()
返回 false,所以循环继续。但是,下一次您试图从流中读取时,它会发现没有更多的输入可用,设置failbit
,并返回一个错误条件。循环条件为false
,循环退出。
如果流包含无效输入,例如整数输入的非数字字符,循环也可能退出;如果输入流中有硬件错误(例如磁盘故障),循环也可能退出。直到现在,本书中的程序都懒得测试为什么循环会退出。然而,要编写一个好的测试程序,你必须知道原因。
首先,您可以通过调用bad()
成员函数来测试硬件或类似的错误,如果设置了badbit
,该函数将返回 true。这意味着文件发生了可怕的事情,而程序无法修复这个问题。
接下来,通过调用eof()
成员函数来测试正常的文件结束,只有当eofbit
被设置时它才是true
。如果bad()
和eof()
都是false
并且fail()
是true
,这意味着该流包含无效输入。您的程序应该如何处理输入失败取决于您的特定环境。一些程序必须立即退出;其他人可能会尝试继续。例如,您的测试程序可以通过调用clear()
成员函数来重置错误状态,然后继续运行。输入失败后,您可能不知道流的位置,所以您不知道流下一步准备读取什么。这个简单的测试程序跳到下一行。
清单 32-3 展示了一个测试程序,它会一直循环直到文件结束或者一个不可恢复的错误发生。如果问题仅仅是无效输入,错误状态被清除,循环继续。
#include <cassert>
import <iostream>;
import <numeric>;
import <sstream>;
... omitted for brevity ...
/// Tests for failbit only
bool iofailure(std::istream& in)
{
return in.fail() and not in.bad();
}
int main()
{
rational r{0};
while (std::cin)
{
if (std::cin >> r)
// Read succeeded, so no failure state is set in the stream.
std::cout << r << '\n';
else if (not std::cin.eof())
{
// Only failbit is set, meaning invalid input. Clear the state,
// and then skip the rest of the input line.
std::cin.clear();
std::cin.ignore(std::numeric_limits<int>::max(), '\n');
}
}
if (std::cin.bad())
std::cerr << "Unrecoverable input failure\n";
}
Listing 32-3.Testing the I/O Operators
rational
型快完成了。下一个探索处理赋值操作符,并试图改进构造器。
三十三、复制和初始化
完成这个阶段的最后一步是编写赋值操作符和改进构造器。原来 C++ 为您做了一些工作,但是您经常想要微调这些工作。让我们找出方法。
赋值运算符
到目前为止,所有的rational
操作符都是自由函数。赋值运算符是不同的。C++ 标准要求它是一个成员函数。清单 33-1 显示了编写这个函数的一种方法。
struct rational
{
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
rational& operator=(rational const& rhs)
{
numerator = rhs.numerator;
denominator = rhs.denominator;
reduce();
return *this;
}
int numerator;
int denominator;
};
Listing 33-1.First Version of the Assignment Operator
有几点需要进一步解释。当您将运算符实现为自由函数时,每个操作数需要一个参数。因此,二元运算符需要一个双参数函数,而一元运算符需要一个单参数函数。成员函数则不同,因为对象本身就是一个操作数(总是左操作数),对象对所有成员函数都是隐式可用的;因此,您需要少一个参数。二元操作符需要一个参数(如清单 33-1 所示),一元操作符不需要参数(示例如下)。
赋值运算符的约定是返回对封闭类型的引用。要返回的值是对象本身。可以用表达式*this
( this
是保留关键字)获取对象。
因为*this
是对象本身,所以引用成员的另一种方式是使用点运算符(例如(*this).numerator
),而不是不加修饰的numerator
。(*this).numerator
的另一种写法是this->numerator
。意思是一样的;备选语法主要是为了方便。对于这些简单的函数来说,编写this->
并不是必须的,但这通常是个好主意。当你读取一个成员函数时,你很难区分成员和非成员,这是一个信号,你必须通过在所有成员名前使用this->
来帮助读者。清单 33-2 显示了明确使用this->
的赋值操作符。
rational& operator=(rational const& that)
{
this->numerator = that.numerator;
this->denominator = that.denominator;
reduce();
return *this;
}
Listing 33-2.Assignment Operator with Explicit Use of this->
右边的操作数可以是你想要的任何东西。例如,您可能想要优化一个整数到一个rational
对象的赋值。赋值操作符与编译器的自动转换规则一起工作的方式是,编译器将这样的赋值(例如,r = 3
)视为临时rational
对象的隐式构造,随后是一个rational
对象到另一个对象的赋值。
编写一个赋值操作符,它带有一个 int
参数。将您的解决方案与我的进行比较,如清单 33-3 所示。
rational& operator=(int num)
{
this->numerator = num;
this->denominator = 1; // no need to call reduce()
return *this;
}
Listing 33-3.Assignment of an Integer to a rational
如果你没有写赋值操作符,编译器会为你写一个。在简单的rational
类型的情况下,结果是编译器编写了一个与清单 32-2 中的完全一样的类型,所以实际上没有必要自己编写(除了教学目的)。当编译器为您编写代码时,读者很难知道实际定义了哪些函数。此外,更难记录隐式函数。所以 C++ 让你明确地声明你希望编译器为你提供一个特殊的函数,方法是在声明(不是定义)后面加上=default
而不是函数体。
rational& operator=(rational const&) = default;
构造器
编译器还会自动编写一个构造器,特别是通过从另一个rational
对象复制所有数据成员来构造一个rational
对象的构造器。这被称为复制构造器。每当您通过值向函数传递一个rational
参数时,编译器使用复制构造器将参数值复制到参数中。任何时候你定义一个rational
变量并用另一个rational
值初始化它,编译器通过调用复制构造器来构造变量。
与赋值操作符一样,编译器的默认实现正是我们自己编写的,所以没有必要编写复制构造器。与赋值运算符一样,您可以明确声明希望编译器提供其默认的复制构造器。
rational(rational const&) = default;
复制构造器的参数类型是引用。好好想想。当通过值传递参数时,编译器使用复制构造器,因此如果复制构造器使用通过值调用,程序将在第一次尝试复制对象时无限递归。所以复制构造器的参数必须是一个引用。几乎总是引用一个const
对象。
如果你没有为一个类型写任何构造器,编译器也会创建一个不带参数的构造器,叫做默认构造器。当您定义自定义类型的变量并且没有为它提供初始值设定项时,编译器使用默认构造器。编译器对默认构造器的实现只是为每个数据成员调用默认构造器。如果数据成员具有内置类型,则该成员保持未初始化状态。换句话说,如果我们没有为rational
编写任何构造器,任何rational
变量都将是未初始化的,因此它的分子和分母将包含垃圾值。这很糟糕——非常糟糕。所有的操作者都假设rational
对象已经被简化为正常形式。如果您向它们传递一个未初始化的rational
对象,它们就会失败。解决方案很简单:不要让编译器编写它的默认构造器。相反,你写一个。
你所要做的就是写一个构造器。这将阻止编译器编写自己的默认构造器。(它仍然会编写自己的复制构造器。)
早期,我们为rational
类型编写了一个构造器,但它不是默认的构造器。因此,您不能定义一个rational
变量并不初始化它或者用空括号初始化它。(您可能在编写自己的测试程序时遇到过这个问题。)未初始化的数据是个坏主意,拥有默认构造器是个好主意。所以写一个默认的构造器来确保一个没有初始化器的rational
变量仍然有一个定义良好的值。你应该使用什么值?我推荐零,这符合string
和vector
等类型的默认构造器的精神。为 rational
写一个默认构造器,将值初始化为零。
将你的解决方案与我的进行比较,我的解决方案在清单 33-4 中给出。
rational()
: rational{0, 1}
{}
Listing 33-4.Overloaded Constructors for rational
把这一切放在一起
在我们离开之前的rational
式(只是暂时的;我们会回来),让我们把所有的碎片放在一起,这样你就可以看到你在过去的四次探索中完成了什么。清单 33-5 显示了rational
和相关操作符的完整定义。
#include <cassert>
#include <cmath>
import <iostream>;
import <numeric>;
import <sstream>;
import test;
/// Represent a rational number (fraction) as a numerator and denominator.
struct rational
{
rational()
: rational{0}
{/*empty*/}
rational(int num)
: numerator{num}, denominator{1}
{/*empty*/}
rational(int num, int den)
: numerator{num}, denominator{den}
{
reduce();
}
rational(double r)
: rational{static_cast<int>(r * 10000), 10000}
{/*empty*/}
rational& operator=(rational const& that)
{
this->numerator = that.numerator;
this->denominator = that.denominator;
return *this;
}
float as_float()
{
return static_cast<float>(numerator) / denominator;
}
double as_double()
{
return static_cast<double>(numerator) / denominator;
}
long double as_long_double()
{
return static_cast<long double>(numerator) / denominator;
}
/// Assign a numerator and a denominator, then reduce to normal form.
void assign(int num, int den)
{
numerator = num;
denominator = den;
reduce();
}
/// Reduce the numerator and denominator by their GCD.
void reduce()
{
assert(denominator != 0);
if (denominator < 0)
{
denominator = -denominator;
numerator = -numerator;
}
int div{std::gcd(numerator, denominator)};
numerator = numerator / div;
denominator = denominator / div;
}
int numerator;
int denominator;
};
/// Absolute value of a rational number.
rational abs(rational const& r)
{
return rational{std::abs(r.numerator), r.denominator};
}
/// Unary negation of a rational number.
rational operator-(rational const& r)
{
return rational{-r.numerator, r.denominator};
}
/// Add rational numbers.
rational operator+(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator + rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
/// Subtraction of rational numbers.
rational operator-(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator - rhs.numerator * lhs.denominator,
lhs.denominator * rhs.denominator};
}
/// Multiplication of rational numbers.
rational operator*(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.numerator, lhs.denominator * rhs.denominator};
}
/// Division of rational numbers.
/// TODO: check for division-by-zero
rational operator/(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator * rhs.denominator, lhs.denominator * rhs.numerator};
}
/// Compare two rational numbers for equality.
bool operator==(rational const& a, rational const& b)
{
return a.numerator == b.numerator and a.denominator == b.denominator;
}
/// Compare two rational numbers for inequality.
inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
/// Compare two rational numbers for less-than.
bool operator<(rational const& a, rational const& b)
{
return a.numerator * b.denominator < b.numerator * a.denominator;
}
/// Compare two rational numbers for less-than-or-equal.
inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
/// Compare two rational numbers for greater-than.
inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
/// Compare two rational numbers for greater-than-or-equal.
inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
/// Read a rational number.
/// Format is @em integer @c / @em integer.
std::istream& operator>>(std::istream& in, rational& rat)
{
int n{0}, d{0};
char sep{'\0'};
if (not (in >> n >> sep))
// Error reading the numerator or the separator character.
in.setstate(in.failbit);
else if (sep != '/')
{
// Push sep back into the input stream, so the next input operation
// will read it.
in.unget();
rat.assign(n, 1);
}
else if (in >> d)
// Successfully read numerator, separator, and denominator.
rat.assign(n, d);
else
// Error reading denominator.
in.setstate(in.failbit);
return in;
}
/// Write a rational numbers.
/// Format is @em numerator @c / @em denominator.
std::ostream& operator<<(std::ostream& out, rational const& rat)
{
std::ostringstream tmp{};
tmp << rat.numerator << '/' << rat.denominator;
out << tmp.str();
return out;
}
int main()
{
TEST(rational{1} == rational{2,2});
... Add tests, lots of tests
}
Listing 33-5.Complete Definition of rational and Its Operators
我鼓励你向清单 33-5 中的程序添加测试,以测试rational
类的所有最新特性。确保一切都按您预期的方式运行。然后将rational
放在一边,进行下一次探索,更深入地了解编写定制类型的基础。
三十四、编写类
rational
类型是类的一个例子。既然您已经看到了编写自己的类的具体示例,那么是时候了解管理所有类的一般规则了。这个探索和接下来的四个探索为 C++ 编程的这个重要方面奠定了基础。
剖析一个班级
一个类有一个名字和成员——数据成员、成员函数,甚至成员类型定义和嵌套类。用关键字struct
开始一个类定义。(您可能想知道为什么不用关键字class
开始一个类定义。请耐心等待;一切都将在探索中变得清晰 36 。)用花括号把类定义体括起来,定义以分号结束。在花括号中,您列出了所有成员。以类似于局部变量定义的方式声明数据成员。编写成员函数的方式与编写自由函数的方式相同。清单 34-1 显示了一个只包含数据成员的简单类定义。
struct point
{
double x;
double y;
};
Listing 34-1.Class Definition for a Cartesian Point
清单 34-2 展示了 C++ 如何让你在一个声明中列出多个数据成员。除了琐碎的类,这种风格并不常见。我更喜欢单独列出每个成员,这样我就可以包含一个注释来解释这个成员,它的用途,什么约束适用于它,等等。即使没有评论,一点额外的澄清也大有帮助。
struct point
{
double x, y;
};
Listing 34-2.Multiple Data Members in One Declaration
与 C++ 源文件中的任何其他名称一样,在使用类名之前,编译器必须看到它的声明或定义。您可以在自己的定义中使用类名。
使用类名作为类型名,定义局部变量、函数参数、函数返回类型,甚至其他数据成员。编译器从类定义的最开始就知道类名,所以你可以在类定义中使用它的名字作为类型名。
当您使用类类型定义变量时,编译器会留出足够的内存,以便变量可以存储该类的每个数据成员的副本。例如,定义一个类型为point
的对象,该对象包含x
和y
成员。定义另一个类型为point
的对象,该对象包含自己独立的x
和y
成员。
使用点(.
)运算符来访问成员,就像您在本书中一直做的那样。对象是左边的操作数,成员名是右边的操作数,如清单 34-3 所示。
import <iostream>;
struct point
{
double x;
double y;
};
int main()
{
point origin{}, unity{};
origin.x = 0;
origin.y = 0;
unity.x = 1;
unity.y = 1;
std::cout << "origin = (" << origin.x << ", " << origin.y << ")\n";
std::cout << "unity = (" << unity.x << ", " << unity.y << ")\n";
}
Listing 34-3.Using a Class and Its Members
成员函数
除了数据成员,您还可以拥有成员函数。成员函数定义看起来非常类似于普通的函数定义,只是您将它们定义为类定义的一部分。此外,成员函数可以调用同一类的其他成员函数,并且可以访问同一类的数据成员。清单 34-4 展示了添加到类point
中的一些成员函数。
#include <cmath> // for sqrt and atan2
struct point
{
/// Distance to the origin.
double distance()
{
return std::sqrt(x*x + y*y);
}
/// Angle relative to x-axis.
double angle()
{
return std::atan2(y, x);
}
/// Add an offset to x and y.
void offset(double off)
{
offset(off, off);
}
/// Add an offset to x and an offset to y
void offset(double xoff, double yoff)
{
x = x + xoff;
y = y + yoff;
}
/// Scale x and y.
void scale(double mult)
{
this->scale(mult, mult);
}
/// Scale x and y.
void scale(double xmult, double ymult)
{
this->x = this->x * xmult;
this->y = this->y * ymult;
}
double x;
double y;
};
Listing 34-4.Member Functions for Class point
对于每个成员函数,编译器都会生成一个名为this
的隐藏参数。当调用成员函数时,编译器将对象作为隐藏参数传递。在成员函数中,可以用表达式*this
访问对象。C++ 语法规则规定成员操作符(.
)的优先级高于*
操作符,因此需要在*this
(例如(*this).x
)两边加上括号。为了语法上的方便,编写相同表达式的另一种方式是this-
> x
,您可以在清单 34-4 中看到几个例子。
编译器足够聪明,知道何时使用成员名,所以使用this->
是可选的。如果一个名字没有局部定义,并且是一个成员的名字,编译器会认为你想使用这个成员。为了清晰起见,一些程序员喜欢总是包含this->
——在一个大程序中,您很容易忘记哪些名称是成员名称。其他程序员发现额外的this->
很杂乱,只在必要时才使用。我的推荐是后者。您需要学习阅读 C++ 类,其中一项必要的技能是能够阅读类定义,找到成员名称,并在阅读类定义时跟踪这些名称。
许多程序员使用一种更微妙的技术,包括使用特殊的前缀或后缀来表示数据成员名称。例如,一种常见的技术是对所有数据成员使用前缀m_
(“m”是成员的缩写)。另一种常见的技术没有那么麻烦:使用普通的下划线(_
)后缀。比起前缀,我更喜欢后缀,因为后缀比前缀干扰少,所以它们不会模糊名字的重要部分。从现在开始,我将采用在每个数据成员名称后添加下划线的做法。
NO LEADING UNDERSCORE
如果您只想使用下划线来表示成员,请将其用作后缀,而不是前缀。C++ 标准将某些名称搁置一边,并禁止您使用它们。实际的规则有些冗长,因为 C++ 从 C 标准库中继承了许多限制。例如,您不应该使用任何以E
开头、后跟数字或大写字母的名称。(这条规则看起来很神秘,但是 C 标准库为数学函数中的范围错误定义了几个错误代码名,比如ERANGE
。该规则允许库在将来添加新名称,并允许那些实现库的人添加特定于供应商的名称。)
我喜欢简单,所以我遵循三个基本原则。这些规则比正式的 C++ 规则稍微严格一些,但并不繁琐:
-
不要使用包含两个连续下划线(
like__this
)的任何名称。 -
不要使用任何以下划线(
_like_this
)开头的名称。 -
不要使用全大写的名称(
LIKE_THIS
)。
使用保留名称会导致未定义的行为。编译器可能不会抱怨,但结果是不可预测的。通常,标准的库实现必须为其内部使用发明许多额外的名称。通过定义应用程序程序员不能使用的某些名称,C++ 确保了库作者可以在库中使用这些名称。如果您不小心使用了与内部库名冲突的名称,结果可能是混乱或者仅仅是函数实现中的细微变化。
构造器
正如你在 Exploration 30 中学到的,构造器是一个特殊的成员函数,它初始化一个对象的数据成员。您已经看到了如何编写构造器的几种变体,现在是时候再学习一些了。
当你声明一个数据成员时,你也可以提供一个初始化器。初始化器是一个缺省值,编译器在构造器没有初始化成员时使用。使用正常的初始化语法,在花括号中提供一个或多个值。
struct point {
int x = 1;
int y;
point() {} // initializes x to 1 and y to 0
};
仅当特定成员需要所有或几乎所有构造器中的单个值时,才使用这种方式初始化数据成员。通过将初始值从构造器中分离出来,使得构造器更难阅读和理解。人类读者必须阅读构造器和数据成员声明,才能知道对象是如何初始化的。另一方面,使用默认初始化器是确保内置类型的数据成员(如int
)总是被初始化的一个好方法。
回想一下,构造器可以重载,编译器根据初始化器中的参数选择调用哪个构造器。我喜欢用花括号初始化一个对象。花括号中的值以与普通函数的函数参数相同的方式传递给构造器。事实上,C++ 03 使用圆括号来初始化对象,所以初始化式看起来非常像函数调用。C++ 的更高版本仍然允许这种风格的初始化式,但是在几乎所有其他情况下,花括号更好。探索 31 演示了花括号提供了更好的类型安全性。
花括号的另一个关键区别是,你可以用花括号中的一系列值初始化一个容器,比如一个vector
,如下所示:
std::vector<int> data{ 1, 2, 3 };
这就引入了一个问题。vector 类型有几个构造器。例如,双参数构造器允许您用单个值的多个副本初始化一个向量。例如,一个有十个零的向量可以初始化如下:
std::vector<int> ten_zeroes(10, 0);
请注意,我使用了括号。如果我用花括号呢?试试看。会发生什么?
向量用两个整数初始化:10 和 0。规则是容器将花括号视为一系列用来初始化容器内容的值。大括号还可以用在其他一些情况下,比如复制一个容器,但是如果构造器的参数看起来像容器值,那么编译器可能会这样解释它们,或者发出一个关于不明确的错误消息。
用与普通成员函数几乎相同的方式编写构造器,但有一些不同:
-
省略返回类型。
-
使用普通的
return;
(不返回值的返回语句)。 -
使用类名作为函数名。
-
在冒号后添加一个初始化列表来初始化数据成员。初始化器也可以调用另一个构造器,将参数传递给那个构造器。将构造委托给一个公共构造器是确保所有构造器都正确执行规则的一个好方法。
清单 34-5 展示了几个添加到类point
的构造器的例子。
struct point
{
point()
: point{0.0, 0.0}
{}
point(double x, double y)
: x_{x}, y_{y}
{}
point(point const& pt)
: point{pt.x_, pt.y_}
{}
double x_;
double y_;
};
Listing 34-5.Constructors for Class point
初始化是类类型和内置类型的主要区别之一。如果你定义一个没有初始化器的内置类型的对象,你会得到一个垃圾值,但是类类型的对象总是通过调用一个构造器来初始化。您总是有机会初始化对象的数据成员。内置类型和类类型之间的区别在 C++ 用来初始化构造器中的数据成员的规则中也很明显。
构造器的初始化列表是可选的,但是我建议你总是提供它,除非每个数据成员都有一个初始化列表。初始值设定项列表出现在冒号之后,冒号跟在构造器参数列表的右括号之后;它按照在类定义中声明的顺序初始化每个数据成员,忽略初始化列表中的顺序。为了避免混淆,总是按照与数据成员相同的顺序编写初始化列表。成员初始值设定项用逗号分隔,可以根据需要任意多行。每个成员初始化器提供单个数据成员的初始值,或者使用类名调用另一个构造器。列出成员名,后面用花括号括起它的初始化式。初始化数据成员与初始化变量相同,遵循相同的规则。
如果你没有为你的类写任何构造器,编译器会写它自己的默认构造器。编译器的默认构造器就像一个省略了初始化列表的构造器。
struct point {
point() {} // x_ is initialized to 0, and y_ is uninitialized
double x_{};
double y_;
};
编译器给你写构造器的时候,构造器是隐式。如果编写任何构造器,编译器会取消隐式默认构造器。如果你想要一个默认的构造器,你必须自己写。
在某些应用程序中,您可能希望避免初始化point
的数据成员的开销,因为您的应用程序会立即为point
对象分配一个新值。然而,大多数时候,谨慎是最好的。
一个复制构造器接受一个与类相同类型的参数,通过引用传递。当您通过值将对象传递给函数时,或者当函数返回对象时,编译器会自动生成对复制构造器的调用。还可以用另一个point
对象的值初始化一个point
对象,编译器生成代码来调用复制构造器。
point pt1; // default constructor
point p2{pt1}; // copy constructor
如果你不写自己的复制构造器,编译器会为你写一个。自动复制构造器调用每个数据成员的复制构造器,就像清单 34-5 中的一样。因为我写了一个和编译器隐式写的一模一样的,所以没有理由显式写。让编译器完成它的工作。
为了帮助你可视化编译器如何调用构造器,请阅读清单 34-6 。注意它是如何为每次构造器的使用打印一条消息的。
import <iostream>;
struct demo
{
demo() : demo{0} { std::cout << "default constructor\n"; }
demo(int x) : x_{x} { std::cout << "constructor(" << x << ")\n"; }
demo(demo const& that)
: x_{that.x_}
{
std::cout << "copy constructor(" << x_ << ")\n";
}
int x_;
};
demo addone(demo d)
{
++d.x_;
return d;
}
int main()
{
demo d1{};
demo d2{d1};
demo d3{42};
demo d4{addone(d3)};
}
Listing 34-6.Visual Constructors
预测运行清单 34-6 中程序的输出。
检查你的预测。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
当向函数传递参数和接受返回值时,编译器会执行一些小的优化。例如,C++ 标准不是将一个demo
对象复制到addone
返回值,然后复制返回值来初始化d4
,而是指导编译器移除对复制构造器的不必要调用。当我运行这个程序时,我得到了这个:
constructor(0)
default constructor
copy constructor(0)
constructor(42)
copy constructor(42)
copy constructor(43)
默认和删除的构造器
如果不提供任何构造器,编译器将隐式编写一个默认构造器和一个复制构造器。如果你写了至少一个任意类型的构造器,编译器不会隐式地写一个默认的构造器,但是如果你自己没有写,它仍然会给你一个复制构造器。
您可以控制编译器的隐式行为,而无需编写任何自己的构造器。为构造器写一个不带体的函数头,用=default
得到编译器的隐式定义。使用=delete
抑制该功能。例如,如果您不希望任何人创建类的副本,请注意以下几点:
struct dont_copy
{
dont_copy(dont_copy const&) = delete;
};
更常见的是让编译器编写它的复制构造器,但是明确地告诉人类读者。随着你对 C++ 了解的越来越多,你会发现编译器为你写构造器的规则,以及何时写构造器的规则,比我目前所介绍的要复杂得多。当你要求编译器隐式地提供一个构造器时,我敦促你养成声明的习惯,即使这看起来很明显。
struct point
{
point() = default;
point(point const&) = default;
int x, y;
};
那很容易。接下来的探索从一个真正的挑战开始。
三十五、关于成员函数的更多信息
成员函数和构造器甚至比你目前所学的更有趣。这一探索继续揭开它们的神秘面纱。
重访项目 1
你觉得项目 1(探索 29 )最让你沮丧的是什么?如果您和我一样(尽管为了您自己,我希望您不是),您可能会对必须定义几个单独的向量来存储一组记录感到失望。然而,在不了解类的情况下,这是唯一可行的方法。既然已经向您介绍了类,您就可以修复程序了。编写一个类定义来存储一条记录。详细信息请参考探索 29 。简而言之,每条记录都记录了以厘米为单位的整数身高、以千克为单位的整数体重、计算出的身体质量指数(可以四舍五入为整数)、这个人的性别(字母'M'
或'F'
)以及这个人的名字(一个string
)。
接下来,编写一个 read 成员函数,从一个 istream
中读取一条记录。它有两个参数:一个istream
和一个整数。通过写信给std::cout
来提示用户每条信息。integer 参数是记录号,可以在提示中使用。编写一个打印成员函数,打印一条记录;它采用一个ostream
和一个整数阈值作为参数。
最后,修改程序以利用你写的新类。将你的解决方案与我的进行比较,如清单 35-1 所示。
#include <cstdlib>
import <algorithm>;
import <iomanip>;
import <iostream>;
import <limits>;
import <locale>;
import <ranges>;
import <string>;
import <vector>;
/// Compute body-mass index from height in centimeters and weight in kilograms.
int compute_bmi(int height, int weight)
{
return static_cast<int>(weight * 10000 / (height * height) + 0.5);
}
/// Skip the rest of the input line.
void skip_line(std::istream& in)
{
in.ignore(std::numeric_limits<int>::max(), '\n');
}
/// Represent one person's record, storing the person's name, height, weight,
/// sex, and body-mass index (BMI), which is computed from the height
and weight.
struct record
{
record() : height_{0}, weight_{0}, bmi_{0}, sex_{'?'}, name_{}
{}
/// Get this record, overwriting the data members.
/// Error-checking omitted for brevity.
/// @return true for success or false for eof or input failure
bool read(std::istream& in, int num)
{
std::cout << "Name " << num << ": ";
std::string name{};
if (not std::getline(in, name))
return false;
std::cout << "Height (cm): ";
int height{};
if (not (in >> height))
return false;
skip_line(in);
std::cout << "Weight (kg): ";
int weight{};
if (not (in >> weight))
return false;
skip_line(in);
std::cout << "Sex (M or F): ";
char sex{};
if (not (in >> sex))
return false;
skip_line(in);
sex = std::toupper(sex, std::locale());
// Store information into data members only after reading
// everything successfully.
name_ = name;
height_ = height;
weight_ = weight;
sex_ = sex;
bmi_ = compute_bmi(height_, weight_);
return true;
}
/// Print this record to @p out.
void print(std::ostream& out, int threshold)
{
out << std::setw(6) << height_
<< std::setw(7) << weight_
<< std::setw(3) << sex_
<< std::setw(6) << bmi_;
if (bmi_ >= threshold)
out << '*';
else
out << ' ';
out << ' ' << name_ << '\n';
}
int height_; ///< height in centimeters
int weight_; ///< weight in kilograms
int bmi_; ///< Body-mass index
char sex_; ///< 'M' for male or 'F' for female
std::string name_; ///< Person’s name
};
/** Print a table
.
* Print a table of height, weight, sex, BMI, and name.
* Print only records for which sex matches @p sex.
* At the end of each table, print the mean and median BMI.
*/
void print_table(char sex, std::vector<record>& records, int threshold)
{
std::cout << "Ht(cm) Wt(kg) Sex BMI Name\n";
float bmi_sum{};
long int bmi_count{};
std::vector<int> tmpbmis{}; // store only the BMIs that are printed
// in order to compute the median
for (auto rec : records)
{
if (rec.sex_ == sex)
{
bmi_sum = bmi_sum + rec.bmi_;
++bmi_count;
tmpbmis.push_back(rec.bmi_);
rec.print(std::cout, threshold);
}
}
// If the vectors are not empty, print basic statistics.
if (bmi_count != 0)
{
std::cout << "Mean BMI = "
<< std::setprecision(1) << std::fixed << bmi_sum / bmi_count
<< '\n';
// Median BMI is trickier. The easy way is to sort the
// vector and pick out the middle item or items.
std::ranges::sort(tmpbmis);
std::cout << "Median BMI = ";
// Index of median item.
std::size_t i{tmpbmis.size() / 2};
if (tmpbmis.size() % 2 == 0)
std::cout << (tmpbmis.at(i) + tmpbmis.at(i-1)) / 2.0 << '\n';
else
std::cout << tmpbmis.at(i) << '\n';
}
}
/** Main program to compute BMI. */
int main()
{
std::locale::global(std::locale{""});
std::cout.imbue(std::locale{});
std::cin.imbue(std::locale{});
std::vector<record> records{};
int threshold{};
std::cout << "Enter threshold BMI: ";
if (not (std::cin >> threshold))
return EXIT_FAILURE;
skip_line(std::cin);
std::cout << "Enter name, height (in cm),"
" and weight (in kg) for each person:\n";
record rec{};
while (rec.read(std::cin, records.size()+1))
{
records.emplace_back(rec);
std::cout << "BMI = " << rec.bmi_ << '\n';
}
// Print the data.
std::cout << "\n\nMale data\n";
print_table('M', records, threshold);
std::cout << "\nFemale data\n";
print_table('F', records, threshold);
}
Listing 35-1.New BMI Program
那是很难接受的,所以慢慢来。我会在这里等你做完。当面对一个你必须阅读和理解的新课堂时,从阅读评论开始(如果有的话)。一种方法是先略读类以识别成员(函数和数据),然后重读类以深入理解成员函数。一次处理一个成员函数。
你可能会问自己为什么我没有重载>>
和<<
操作符来读写record
对象。该计划的要求比这些运营商提供的稍微复杂一些。例如,读取一个record
还涉及到打印提示,每个提示都包含一个序号,因此用户知道该键入哪条记录。根据阈值的不同,某些记录的打印方式会有所不同。>>
操作者没有方便的方法来指定阈值。重载 I/O 操作符对于简单类型来说很好,但是通常不适合更复杂的情况。
常量成员函数
仔细看看print_table
函数。注意到它的参数有什么不寻常或可疑的地方吗?records
参数是通过引用传递的,但是函数从不修改它,所以你真的应该把它作为引用传递给const
。去改变吧。会发生什么?
您应该会看到编译器出错。当records
为const
时,auto rec : records
类型也必须声明rec
为const
。因此,当print_table
调用rec.print()
时,在print()
函数内部,这指的是一个const record
对象。虽然print()
不修改record
对象,但是它可以,而且编译器必须考虑到这种可能性。你必须告诉编译器print()
是安全的,不修改任何数据成员。通过在print()
函数签名和函数体之间添加一个const
修饰符来实现。清单 35-2 显示了print
成员函数的新定义。
/// Print this record to @p out.
void print(std::ostream& out, int threshold)
const
{
out << std::setw(6) << height_
<< std::setw(7) << weight_
<< std::setw(3) << sex_
<< std::setw(6) << bmi_;
if (bmi_ >= threshold)
out << '*';
else
out << ' ';
out << ' ' << name_ << '\n';
}
Listing 35-2.Adding the const Modifier to print
一般来说,对任何不改变任何数据成员的成员函数使用const
修饰符。这确保了当您有一个const
对象时,您可以调用成员函数。复制清单 34-4 中的代码,并对其进行修改,在适当的地方添加 const
修饰符。将您的结果与清单 35-3 中的结果进行比较。
#include <cmath> // for sqrt and atan2
struct point
{
/// Distance to the origin.
double distance()
const
{
return std::sqrt(x*x + y*y);
}
/// Angle relative to x-axis.
double angle()
const
{
return std::atan2(y, x);
}
/// Add an offset to x and y.
void offset(double off)
{
offset(off, off);
}
/// Add an offset to x and an offset to y
void offset(double xoff, double yoff)
{
x = x + xoff;
y = y + yoff;
}
/// Scale x and y.
void scale(double mult)
{
this->scale(mult, mult);
}
/// Scale x and y.
void scale(double xmult, double ymult)
{
this->x = this->x * xmult;
this->y = this->y * ymult;
}
double x;
double y;
};
Listing 35-3.const Member Functions for Class point
scale
和offset
函数修改数据成员,所以不能是const
。angle
和distance
成员函数不修改任何成员,所以它们是const
。
给定一个point
变量,你可以调用任何成员函数。然而,如果对象是const
,你只能调用const
成员函数。最常见的情况是当您发现自己在另一个函数中有一个const
对象,并且该对象是通过引用const
传递的,如清单 35-4 所示。
#include <cmath>
import <iostream>;
// Use the same point definition as Listing 35-3
... omitted for brevity ...
void print_polar(point const& pt)
{
std::cout << "{ r=" << pt.distance() << ", angle=" << pt.angle() << " }\n";
}
void print_cartesian(point const& pt)
{
std::cout << "{ x=" << pt.x << ", y=" << pt.y << " }\n";
}
int main()
{
point p1{}, p2{};
double const pi{3.141592653589792};
p1.x = std::cos(pi / 3);
p1.y = std::sin(pi / 3);
print_polar(p1);
print_cartesian(p1);
p2 = p1;
p2.scale(4.0);
print_polar(p2);
print_cartesian(p2);
p2.offset(0.0, -2.0);
print_polar(p2);
print_cartesian(p2);
}
Listing 35-4.Calling const and Non-const Member Functions
成员函数的另一个常见用途是限制对数据成员的访问。想象一下,如果一个使用身体质量指数record
类型的程序不小心修改了bmi_
成员,会发生什么。更好的设计是让您调用一个bmi()
函数来获取身体质量指数,但隐藏bmi_
数据成员,以防止意外修改。您可以预防此类事故,接下来的探索将向您展示如何预防。
三十六、访问级别
每个人都有秘密,有些人比其他人多。班级也有秘密。例如,在这本书里,你使用了std::string
类,却不知道该类内部发生了什么。实现细节是秘密——不是严密保护的秘密,但仍然是秘密。您不能直接检查或修改任何string
的数据成员。相反,它提供了相当多的组成其公共接口的成员函数。您可以自由使用任何公开可用的成员函数,但只能使用公开可用的成员函数。这个探索解释了如何在你的类中做同样的事情。
公共与私有
一个类的作者决定哪些成员是秘密的(仅供该类自己的成员函数使用),哪些成员可供程序中的任何其他代码自由使用。秘密成员称为私人,任何人都可以使用的成员称为公共。隐私设置被称为访问级别。(当你阅读 C++ 代码时,你可能会看到另一个访问级别,protected
。我稍后会谈到这一点。两个访问级别就足够了。)
要指定访问级别,请使用private
关键字或public
关键字,后跟一个冒号。类定义中的所有后续成员都具有该可访问性级别,直到您用新的访问级别关键字对其进行更改。清单 36-1 显示了带有访问级别说明符的point
类。
struct point
{
public:
point() : point{0.0, 0.0} {}
point(double x, double y) : x_{x}, y_{y} {}
point(point const&) = default;
double x() const { return x_; }
double y() const { return y_; }
double angle() const { return std::atan2(y(), x()); }
double distance() const { return std::sqrt(x()*x() + y()*y()); }
void move_cartesian(double x, double y)
{
x_ = x;
y_ = y;
}
void move_polar(double r, double angle)
{
move_cartesian(r * std::cos(angle), r * std::sin(angle));
}
void scale_cartesian(double s) { scale_cartesian(s, s); }
void scale_cartesian(double xs, double ys)
{
move_cartesian(x() * xs, y() * ys);
}
void scale_polar(double r) { move_polar(distance() * r, angle()); }
void rotate(double a) { move_polar(distance(), angle() + a); }
void offset(double o) { offset(o, o); }
void offset(double xo, double yo) { move_cartesian(x() + xo, y() + yo); }
private:
double x_;
double y_;
};
Listing 36-1.The point Class with Access-Level Specifiers
数据成员是私有的,所以唯一可以修改它们的函数是point
自己的成员函数。公共成员函数通过公共成员函数x()
和y()
提供对职位的访问。
Tip
始终保持数据成员私有,并且只通过成员函数提供访问。
要修改位置,请注意point
不允许用户任意分配新的 x 或 y 值。相反,它提供了几个公共成员函数来将点移动到绝对位置或相对于当前位置。
公共成员函数允许您在笛卡尔坐标中工作——即熟悉的 x 和 y 位置,或者在极坐标中工作,指定一个位置作为角度(相对于 x 轴)和离原点的距离。点的两种表示都有其用途,并且都可以唯一地指定二维空间中的任何位置。一些用户更喜欢极坐标符号,而另一些用户更喜欢笛卡尔坐标。两个用户都不能直接访问数据成员,所以point
类如何存储坐标并不重要。事实上,只需更改几个成员函数,就可以更改point
的实现,将距离和角度存储为数据成员。您需要更改哪些成员函数?
将数据成员从x_
和y_
更改为r_
和angle_
需要更改x
、y
、angle
和distance
成员函数,只是为了访问数据成员。你还得改变两个move
功能:move_polar
和move_cartesian
。最后,您必须修改构造器。没有必要进行其他更改。因为scale
和offset
函数不直接访问数据成员,而是调用其他成员函数,所以它们不受类实现变化的影响。重写 point
类,在其数据成员中存储极坐标。比较你的类和我的类,如清单 36-2 所示。
struct point
{
public:
point() : point{0.0, 0.0} {}
point(double x, double y) : r_{0.0}, angle_{0.0} { move_cartesian(x, y); }
point(point const&) = default;
double x() const { return distance() * std::cos(angle()); }
double y() const { return distance() * std::sin(angle()); }
double angle() const { return angle_; }
double distance() const { return r_; }
void move_cartesian(double x, double y)
{
move_polar(std::sqrt(x*x + y*y), std::atan2(y, x));
}
void move_polar(double r, double angle)
{
r_ = r;
angle_ = angle;
}
void scale_cartesian(double s) { scale_cartesian(s, s); }
void scale_cartesian(double xs, double ys)
{
move_cartesian(x() * xs, y() * ys);
}
void scale_polar(double r) { move_polar(distance() * r, angle()); }
void rotate(double a) { move_polar(distance(), angle() + a); }
void offset(double o) { offset(o, o); }
void offset(double xo, double yo) { move_cartesian(x() + xo, y() + yo); }
private:
double r_;
double angle_;
};
Listing 36-2.The point Class Changed to Store Polar Coordinates
一个小困难是构造器。理想情况下,point
应该有两个构造器,一个取极坐标,一个取笛卡尔坐标。问题是两组坐标都是成对的数字,重载不能区分参数。这意味着不能对这些构造器使用普通重载。相反,您可以添加第三个参数:一个标志,指示是将前两个参数解释为极坐标还是笛卡尔坐标。
point(double a, double b, bool is_polar)
{
if (is_polar)
move_polar(a, b);
else
move_cartesian(a, b);
}
这有点像黑客攻击,但现在必须这么做。在本书的后面,您将学习更清洁的技术来完成这项任务。
class
对struct
探索 35 暗示了class
关键字以某种方式包含在类定义中,尽管本书中的每个例子都使用了struct
关键字。现在是了解真相的时候了。
道理很简单。struct
和class
关键字都开始类定义。唯一的区别是默认的访问级别:class
的private
和struct
的public
。仅此而已。
按照惯例,程序员倾向于使用class
进行类定义。一个常见的(但不是通用的)惯例是从公共接口开始类定义,将私有成员隐藏在类定义的底部。清单 36-3 展示了point
类的最新版本,这次是使用class
关键字定义的。
class point
{
public:
point() : r_{0.0}, angle_{0.0} {}
double x() const { return distance() * std::cos(angle()); }
double y() const { return distance() * std::sin(angle()); }
double angle() const { return angle_; }
double distance() const { return r_; }
... other member functions omitted for brevity ...
private:
double r_;
double angle_;
};
Listing 36-3.The point Class
Defined with the class Keyword
公立还是私立?
通常,您可以很容易地确定哪些成员应该是公共的,哪些应该是私有的。然而,有时候你必须停下来思考。考虑一下rational
级(最后一次出现在探索 34 )。重写 rational
类以利用访问级别。
你决定将reduce()
公开还是保密?我选择了 private,因为不需要任何外部来电者呼叫reduce()
。相反,唯一可以调用reduce()
的成员函数是那些改变数据成员本身的函数。因此,reduce()
对外部视图是隐藏的,并且用作实现细节。你隐藏的细节越多越好,因为这让你的类更容易使用。
当您添加访问功能时,您是否只让呼叫者更改分子?你写了一个只改变分母的函数吗?还是要求用户同时分配两者?一个rational
对象的用户应该把它当作一个单独的实体,一个数字。你不能只给浮点数分配一个新的指数,也不能只给有理数分配一个新的分子。另一方面,我认为没有理由不让呼叫者只检查分子或分母。例如,您可能想要编写自己的输出格式化函数,这需要分别知道分子和分母。
您做出正确选择的一个好迹象是,您可以轻松地重写所有操作符函数。这些函数应该不必访问rational
的数据成员,而只使用公共函数。如果你试图访问任何私有成员,你很快就会发现编译器不会允许你这样做。这就是隐私的意义。
将您的解决方案与我的解决方案进行比较,如清单 36-4 所示。
#include <cassert>
#include <cstdlib>
import <iostream>;
import <numeric>;
import <sstream>;
/// Represent a rational number (fraction) as a numerator and denominator.
class rational
{
public:
rational(): rational{0} {}
rational(int num): numerator_{num}, denominator_{1} {} // no need to reduce
rational(rational const&) = default;
rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
rational(double r)
: rational{static_cast<int>(r * 100000), 100000}
{
reduce();
}
int numerator() const { return numerator_; }
int denominator() const { return denominator_; }
float to_float()
const
{
return static_cast<float>(numerator()) / denominator();
}
double to_double()
const
{
return static_cast<double>(numerator()) / denominator();
}
long double to_long_double()
const
{
return static_cast<long double>(numerator()) /
denominator();
}
/// Assign a numerator and a denominator, then reduce to normal form.
void assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
private:
/// Reduce the numerator and denominator by their GCD.
void reduce()
{
assert(denominator() != 0);
if (denominator() < 0)
{
denominator_ = -denominator();
numerator_ = -numerator();
}
int div{std::gcd(numerator(), denominator())};
numerator_ = numerator() / div;
denominator_ = denominator() / div;
}
int numerator_;
int denominator_;
};
/// Absolute value of a rational number.
rational abs(rational const& r)
{
return rational{std::abs(r.numerator()), r.denominator()};
}
/// Unary negation of a rational number.
rational operator-(rational const& r)
{
return rational{-r.numerator(), r.denominator()};
}
/// Add rational numbers.
rational operator+(rational const& lhs, rational const& rhs)
{
return rational{
lhs.numerator() * rhs.denominator() + rhs.numerator() * lhs.denominator(),
lhs.denominator() * rhs.denominator()};
}
/// Subtraction of rational numbers.
rational operator-(rational const& lhs, rational const& rhs)
{
return rational{
lhs.numerator() * rhs.denominator() - rhs.numerator() * lhs.denominator(),
lhs.denominator() * rhs.denominator()};
}
/// Multiplication of rational numbers.
rational operator*(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator() * rhs.numerator(),
lhs.denominator() * rhs.denominator()};
}
/// Division of rational numbers.
/// TODO: check for division-by-zero
rational operator/(rational const& lhs, rational const& rhs)
{
return rational{lhs.numerator() * rhs.denominator(),
lhs.denominator() * rhs.numerator()};
}
/// Compare two rational numbers for equality.
bool operator==(rational const& a, rational const& b)
{
return a.numerator() == b.numerator() and a.denominator() == b.denominator();
}
/// Compare two rational numbers for inequality.
inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
/// Compare two rational numbers for less-than.
bool operator<(rational const& a, rational const& b)
{
return a.numerator() * b.denominator() < b.numerator() * a.denominator();
}
/// Compare two rational numbers for less-than-or-equal.
inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
/// Compare two rational numbers for greater-than.
inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
/// Compare two rational numbers for greater-than-or-equal.
inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
/// Read a rational number.
/// Format is @em integer @c / @em integer.
std::istream& operator>>(std::istream& in, rational& rat)
{
int n{}, d{};
char sep{};
if (not (in >> n >> sep))
// Error reading the numerator or the separator character.
in.setstate(in.failbit);
else if (sep != '/')
{
// Push sep back into the input stream, so the next input operation
// will read it.
in.unget();
rat.assign(n, 1);
}
else if (in >> d)
// Successfully read numerator, separator, and denominator.
rat.assign(n, d);
else
// Error reading denominator.
in.setstate(in.failbit);
return in;
}
/// Write a rational numbers.
/// Format is @em numerator @c / @em denominator.
std::ostream& operator<<(std::ostream& out, rational const& rat)
{
std::ostringstream tmp{};
tmp << rat.numerator() << '/' << rat.denominator();
out << tmp.str();
return out;
}
Listing 36-4.The Latest Rewrite of the rational Class
类是面向对象编程的基本构件之一。现在您已经知道了类是如何工作的,您可以看到它们如何应用于这种风格的编程,这是下一篇文章的主题。
三十七、理解面向对象编程
这个探索从 C++ 编程中脱离出来,转向面向对象编程(OOP)的主题。你可能已经对这个话题很熟悉了,但是我劝你继续读下去。你可能会学到一些新东西。对于其他人来说,这个探索概括地介绍了 OOP 的一些基础。后面的探索将展示 C++ 如何实现 OOP 原则。
书籍和杂志
书和杂志的区别是什么?是的,我很想让你写下你的答案。尽可能多地写下你能想到的不同之处。
书籍和杂志有哪些相似之处?尽可能多地写下你能想到的相似之处。
如果可以的话,把你的清单和其他人写的清单进行比较。他们不一定是程序员;大家都知道什么是书和杂志。问问你的朋友和邻居;在公交车站拦住陌生人,问他们。试着找出一组核心的共性和差异。
清单上的许多项目将是合格的。例如,“大多数书至少有一个作者”,“许多杂志每月出版”,等等。没关系。在解决现实问题时,我们常常根据手头问题的具体需求,将“也许”和“有时”映射为“从不”或“总是”。请记住,这是一个 OOP 练习,而不是书店或库练习。
现在对共同点和不同点进行分类。我不是告诉你如何分类。试着找出一小组涵盖你清单上不同项目的类别。一些不太有用的分类是按字数分组,按最后一个字母分组。试着找到有用的类别。写下来。
我提出了两大类:属性和动作。属性描述书籍和杂志的物理特征:
-
书籍和杂志有大小(页数)和成本。
-
大多数书都有 ISBN(国际标准书号)。
-
大多数杂志都有 ISSN(国际标准序列号)。
-
杂志有卷号和期号。
书籍和杂志都有标题和出版商。书有作者。杂志通常不会。(杂志文章都有作者,但一本杂志整体很少列出一个作者。)
行动描述一本书或一本杂志如何行动,或者你如何与他们互动:
-
你可以看一本书或杂志。一本书或杂志可以打开或合上。
-
你可以购买一本书或杂志。
-
你可以订阅杂志。
属性和动作之间的主要区别在于属性特定于单个对象。动作由一个公共类的所有对象共享。有时,动作被称为行为。所有的狗都表现出气喘吁吁的行为;他们都以几乎相同的方式和相同的原因喘气。所有的狗都有颜色属性,但是一只狗是金色的,另一只狗是黑色的,在树旁边的那只狗是白色带黑色斑点的。
在编程术语中,类描述了该类所有对象的行为或动作以及属性类型。每个对象对于该类枚举的属性都有自己的值。用 C++ 的术语来说,成员函数实现动作并提供对属性的访问,数据成员存储属性。
分类
书和杂志本身没什么作用。相反,他们的“行动”取决于我们如何与他们互动。书店通过销售、储存和广告来与书籍和杂志互动。库的行为包括借出和接受归还。其他种类的对象有自己启动的动作。例如,狗有哪些行为?
狗有哪些属性?
一只猫怎么样?猫和狗有明显不同的行为吗? ____________ 属性?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _总结不同之处。
我不养狗或猫,所以我的观察很有限。从我坐的地方来看,狗和猫有很多相似的属性和行为。我希望许多读者比我更敏锐,能够枚举出这两种动物之间的许多不同之处。
尽管如此,我坚持认为,一旦你仔细考虑这些差异,你会发现它们中的许多并不是某一种动物所特有的属性或行为,而仅仅是某一属性的不同价值或某一行为的不同细节。猫可能更挑剔,但狗和猫都表现出梳理行为。狗和猫有不同的颜色,但它们都有彩色的皮毛(很少有例外)。
换句话说,当试图枚举各种对象的属性和行为时,通过将相似的对象分类在一起,您的工作可以变得更简单。对于生物来说,生物学家已经为我们做了艰苦的工作,他们设计了丰富而详细的动物分类学。因此,一个物种(猫或家猫)属于一个属(猫或犬),是一个科(猫科或犬科)的一部分。这些又进一步分为一个目(食肉目),一个纲(哺乳动物),等等,直到动物界(后生动物)。(分类学家们,请原谅我的过于简单化。)
那么,当你沿着分类树向上爬时,属性和行为会发生什么变化呢?所有哺乳动物的哪些属性和行为是相同的?
所有动物?
随着分类变得更广泛,属性和行为也变得更普遍。狗和猫的属性包括皮毛的颜色、尾巴的长度、体重等等。不是所有的哺乳动物都有皮毛或尾巴,所以你需要整个类有更广泛的属性。重量仍然有效,但是你可能想用尺寸来代替总长度。除了毛发的颜色,你只需要普通的颜色。对所有动物来说,属性都很宽泛:大小、重量、单细胞还是多细胞,等等。
行为都差不多。你可以枚举出猫会咕噜叫,狗会喘气,这两种动物都会走和跑,等等。所有的哺乳动物都吃和喝。雌性哺乳动物哺育幼仔。对所有动物来说,你只剩下一个简短的清单:进食和繁殖。当你试图列出从变形虫到斑马的所有动物的共同行为时,很难比这更具体了。
分类树有助于生物学家了解自然界。类树(通常被称为类层次,因为大词让我们觉得自己很重要)帮助程序员在软件中模拟自然世界(或者模拟非自然世界,这在我们的许多项目中经常发生)。程序员更喜欢任何类层次的局部递归视图,而不是试图命名树的每一层。沿着树向上,每个类都有一个基类,也称为超类或父类。因此,动物是哺乳动物的基类,哺乳动物是狗的基类。向下是派生的类,也称为子类或子类。狗是哺乳动物的派生类。图 37-1 展示了一个类层次结构。箭头从派生类指向基类。
图 37-1。
类图
一个直接基类是没有中间基类的基类。例如, catus 的直接基类是 Felis ,它有一个 Felidae 的直接基类,后者有一个食肉动物的直接基类。后生动物、哺乳动物、食肉动物、猫科动物和猫科动物都是猫科动物的基类,但只有猫科动物是它的直接基类。
继承
正如哺乳动物具有动物的所有属性和行为,狗具有所有哺乳动物的属性和行为一样,在 OOP 语言中,派生类具有其所有基类的所有行为和属性。最常用的术语是继承:派生类继承其基类的行为和属性。这个术语有些不幸,因为 OOP 继承与真实世界的继承完全不同。当派生类继承行为时,基类保留其行为。在现实世界中,类不继承任何东西;物体会。
在现实世界中,一个人对象继承了某些属性(现金、股票、不动产等)的值。)来自一个已故的祖先对象。在 OOP 世界中,person
类通过共享基类中定义的那些行为函数的单个副本,从基类(如primate
)继承行为。一个person
类继承了一个基类的属性,所以派生类的对象包含了在它的类和所有基类中定义的所有属性的值。随着时间的推移,继承术语对您来说会变得很自然。
因为继承创建了一个树状结构,所以树的术语也充斥着对继承的讨论。正如编程中常见的那样,树形图是上下颠倒绘制的,树根在上面,树叶在下面(如图 36-1 所示)。一些 OOP 语言(Java,Smalltalk,Delphi)有一个单一的根,它是所有类的最终基类。其他的,比如 C++,就没有。任何类都可以是自己继承树的根。
到目前为止,继承的主要例子涉及某种形式的特化。猫比哺乳动物更专业,而哺乳动物比动物更专业。计算机编程也是如此。例如,图形用户界面(GUI)的类框架通常使用特化类的层次结构。图 37-2 显示了组成 wxWidgets 的一些更重要的类的选择,wxWidgets 是一个支持许多平台的开源 C++ 框架。
图 37-2。
wxWidgets 类层次结构摘录
即使 C++ 不需要单个根类,但有些框架需要;wxWidgets 确实需要一个根类。大多数 wxWidgets 类都源自wxObject
。有些对象很简单,比如wxPen
和wxBrush
。交互对象源自wxEvtHandler
(“事件处理程序”的简称)。因此,类树中的每一步都引入了另一种程度的特化。
在本书的后面,您将看到继承的其他用途,但最常见也是最重要的用途是从更通用的基类创建专门的派生类。
利斯科夫替代原理
当派生类专门处理基类的行为和属性时(这是常见的情况),您编写的任何涉及基类的代码都应该与派生类的对象一起工作。换句话说,喂养哺乳动物的行为,从广义上来说,是相同的,与具体的动物种类无关。
Barbara Liskov 和 Jeannette Wing 将这一面向对象编程的基本原则形式化,这一原则现在通常被称为替代原则或 Liskov 的替代原则。简而言之,替换原则规定,如果你有基类 B 和派生类 D ,在任何需要类型 B 的对象的情况下,你都可以替换类型 D 的对象,而不会产生不良影响。换句话说,如果你需要一只哺乳动物,任何哺乳动物,有人给你一只狗,你应该可以使用那只狗。如果有人送给你一只猫、一匹马或一头牛,你可以用那种动物。然而,如果有人递给你一条鱼,你可以用任何你认为合适的方式拒绝这条鱼。
替代原则有助于你编写程序,但也带来了负担。它之所以有帮助,是因为它让您可以自由地编写依赖于基类行为的代码,而不用担心任何派生类。例如,在 GUI 框架中,基类wxEvtHandler
可能能够识别鼠标点击并将其分派给事件处理程序。点击处理程序不知道也不关心这个控件实际上是一个wxListCtrl
控件、一个wxTreeCtrl
控件还是一个wxButton
。重要的是wxEvtHandler
接受一个点击事件,获取位置,确定哪个鼠标按钮被点击,等等,然后将这个事件发送给事件处理程序。
责任落在了wxButton
、wxListCtrl
和wxTreeCtrl
类的作者身上,以确保他们的点击行为符合替换原则的要求。满足要求的最简单方法是让派生类继承基类的行为。然而,有时派生类有额外的工作要做。它不是继承,而是提供新的行为。在这种情况下,程序员必须确保该行为是基类行为的有效替代。接下来的几个探索将展示这个抽象原理的具体例子。
类型多态性
在回到 C++-land 之前,我想提出一个更普遍的原则。假设我给你一个标有“哺乳动物”的盒子盒子里可以是任何哺乳动物:狗、猫、人等等。你知道盒子里装不下一只鸟、一条鱼、一块石头或一棵树。里面一定有哺乳动物。程序员称这个盒子为多态的,来自希腊语,意思是“多种形式”。盒子可以容纳许多形式中的任何一种,也就是说,任何一种哺乳动物,不管它是哪种形式的哺乳动物。
虽然很多程序员使用的是通用术语多态性,但是这种具体的多态性是类型多态性,也称为子类型多态性。也就是说,变量(或框)的类型决定了它可以包含哪些类型的对象。多态变量(或盒子)可以包含多种对象类型中的一种。
特别是,具有基类类型的变量可以引用基类类型的对象或从该基类派生的任何类型的对象。根据替换原则,您可以编写代码来使用基类变量,调用基类的任何成员函数,并且该代码将工作,而不管对象的真实派生类型。
既然您已经对 OOP 的原理有了基本的理解,那么是时候看看它们在 C++ 中是如何发挥作用的了。
三十八、继承
前面的探索介绍了一般的 OOP 原则。现在是时候看看如何将这些原则应用到 C++ 中了。
驾驶课程
定义派生类就像定义任何其他类一样,除了在冒号后包含基类访问级别和名称。参见清单 38-1 中一些支持库的简单类的例子。库里的每一件物品都是某种作品:一本书、一本杂志、一部电影等等。为了简单起见,类work
只有两个派生类,book
和periodical
。
import <iostream>;
import <string>;
import <string_view>;
class work
{
public:
work() = default;
work(work const&) = default;
work(std::string_view id, std::string_view title) : id_{id}, title_{title} {}
std::string const& id() const { return id_; }
std::string const& title() const { return title_; }
private:
std::string id_;
std::string title_;
};
class book : public work
{
public:
book() : work{}, author_{}, pubyear_{} {}
book(book const&) = default;
book(std::string_view id, std::string_view title, std::string_view author,
int pubyear)
: work{id, title}, author_{author}, pubyear_{pubyear}
{}
std::string const& author() const { return author_; }
int pubyear() const { return pubyear_; }
private:
std::string author_;
int pubyear_; ///< year of publication
};
class periodical : public work
{
public:
periodical() : work{}, volume_{0}, number_{0}, date_{} {}
periodical(periodical const&) = default;
periodical(std::string_view id, std::string_view title, int volume,
int number,
std::string_view date)
: work{id, title}, volume_{volume}, number_{number}, date_{date}
{}
int volume() const { return volume_; }
int number() const { return number_; }
std::string const& date() const { return date_; }
private:
int volume_; ///< volume number
int number_; ///< issue number
std::string date_; ///< publication date
};
int main()
{
book b{"1", "Exploring C++ 20", "Ray Lischner", 2020};
periodical p{"2", "The C++ Times", 1, 1, "Jan 1, 2020"};
std::cout << b.title() << '\n' <<
p.title() << '\n';
}
Listing 38-1.Defining a Derived Class
当您使用struct
关键字定义一个类时,默认的访问级别是public
。对于class
关键字,默认为private
。这些关键字也会影响派生类。除了在极少数情况下,public
是这里的正确选择,这是我用来编写清单 37-1 中的类的。
同样在清单 38-1 中,注意初始化列表有一些新的东西。派生类可以(也应该)通过列出基类名及其初始化器来初始化它的基类。通过传递正确的参数,可以调用任何构造器。如果在初始化列表中省略了基类,编译器将使用基类的默认构造器。
如果基类没有默认的构造器,你认为会发生什么?
试试看。将work
的默认构造器从= default
改为= delete
,并尝试编译清单 38-1 的代码。会发生什么?
没错;编译器报错。您收到的确切错误消息因编译器而异。我得到了如下的信息:
$ g++ -ansi -pedantic list3801err.cpp
list3801err.cpp: In constructor ‘book::book()’:
list3801err.cpp:17:41: error: use of deleted function ‘work::work()’
book() : work{}, author_{}, pubyear_{0} {}
^
list3801err.cpp:4:3: error: declared here
work() = delete;
^
list3801err.cpp: In constructor ‘periodical::periodical()’:
list3801err.cpp:33:56: error: use of deleted function ‘work::work()’
periodical() : work{}, volume_{0}, number_{0}, date_{} {}
^
list3801err.cpp:4:3: error: declared here
work() = delete;
^
基类总是在成员之前初始化,从类树的根开始。您可以通过编写打印来自其构造器的消息的类来了解这一点,如清单 38-2 所示。
import <iostream>;
class base
{
public:
base() { std::cout << "base\n"; }
};
class middle : public base
{
public:
middle() { std::cout << "middle\n"; }
};
class derived : public middle
{
public:
derived() { std::cout << "derived\n"; }
};
int main()
{
derived d;
}
Listing 38-2.Printing Messages from Constructors to Illustrate Order of Construction
您期望清单 38-2 中的程序产生什么输出?
试试看。你实际得到了什么输出?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 为了彻底起见,我收到以下内容:
base
middle
derived
记住,如果你从初始化器中省略了基类,或者你完全省略了初始化器列表,基类的默认构造器被调用。清单 38-2 只包含默认构造器,所以发生的事情是derived
的构造器首先调用middle
的默认构造器。middle
的构造器首先调用base
的默认构造器,base
的构造器除了执行它的函数体之外什么也不做。然后它返回,并且middle
的构造器体执行并返回,最后让derived
运行它的函数体。
成员函数
派生类继承基类的所有成员。这意味着派生类可以调用任何公共成员函数并访问任何公共数据成员。派生类的任何用户也可以。因此,你可以调用一个book
对象的id()
和title()
函数,并且调用work::id()
和work::title()
函数。
访问级别影响派生类,因此派生类不能访问基类的任何私有成员。(在探索 69 中,你将会学到第三个访问级别,当授予对派生类的访问权限时,它保护成员免受外界窥探。)因此,periodical
类不能访问id_
或title_
数据成员,因此派生类不能意外更改work
的身份或标题。这样,访问级别确保了类的完整性。只有声明数据成员的类才能更改它,因此它可以验证所有更改、阻止更改,或者控制谁更改值以及如何更改。
如果派生类声明了与基类同名的成员函数,则派生类函数是派生类中唯一可见的函数。派生类中的函数被称为影子基类中的函数。通常,您希望避免这种情况,但是在一些情况下,您非常希望使用相同的名称,而不隐藏基类函数。在接下来的探索中,您将了解一个这样的案例。以后,你会学习别人。
析构函数
当一个对象被销毁时——可能是因为定义它的函数结束并返回——有时您必须做一些清理工作。一个类有另一个特殊的成员函数,当一个对象被销毁时执行清理。这个特殊的成员函数被称为析构函数。
像构造器一样,析构函数也没有返回值。析构函数名是类名前面加一个波浪号(~
)。清单 38-3 向清单 38-2 中的示例类添加了析构函数。
import <iostream>;
class base
{
public:
base() { std::cout << "base\n"; }
~base() { std::cout << "~base\n"; }
};
class middle : public base
{
public:
middle() { std::cout << "middle\n"; }
~middle() { std::cout << "~middle\n"; }
};
class derived : public middle
{
public:
derived() { std::cout << "derived\n"; }
~derived() { std::cout << "~derived\n"; }
};
int main()
{
derived d;
}
Listing 38-3.Order of Calling Destructors
你期望清单 37-3 中的程序输出什么?
试试看。你实际得到了什么?
你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 当一个函数返回时,它以构造的逆序销毁所有本地对象。当析构函数运行时,它首先通过运行析构函数的函数体来销毁派生类。然后它调用直接基类析构函数。因此,在下面的示例中,析构函数以相反的构造顺序运行:
base
middle
derived
~derived
~middle
~base
如果你不写析构函数,编译器会为你写一个简单的。无论是您自己编写析构函数,还是编译器隐式编写析构函数,在每个析构函数体完成后,编译器都会为每个数据成员调用析构函数,然后为基类执行析构函数,从派生程度最高的析构函数开始。对于这些例子中的简单类,编译器的析构函数工作得很好。稍后,你会发现析构函数更有趣的用法。目前,主要目的只是可视化对象的生命周期。
仔细阅读清单 38-4 。
import <iostream>;
class base
{
public:
base(int value) : value_{value} { std::cout << "base(" << value << ")\n"; }
base() : base{0} { std::cout << "base()\n"; }
base(base const& copy)
: value_{copy.value_}
{ std::cout << "copy base(" << value_ << ")\n"; }
~base() { std::cout << "~base(" << value_ << ")\n"; }
int value() const { return value_; }
base& operator++()
{
++value_;
return *this;
}
private:
int value_;
};
class derived : public base
{
public:
derived(int value): base{value} { std::cout << "derived(" << value << ")\n"; }
derived() : base{} { std::cout << "derived()\n"; }
derived(derived const& copy)
: base{copy}
{ std::cout << "copy derived(" << value() << "\n"; }
~derived() { std::cout << "~derived(" << value() << ")\n"; }
};
derived make_derived()
{
return derived{42};
}
base increment(base b)
{
++b;
return b;
}
void increment_reference(base& b)
{
++b;
}
int main()
{
derived d{make_derived()};
base b{increment(d)};
increment_reference(d);
increment_reference(b);
derived a(d.value() + b.value());
}
Listing 38-4.Constructors and Destructors
在表格 38-1 的左栏中填入您期望的程序输出。
表 38-1。
运行清单 38-4 中程序的预期和实际结果
|预期产出
|
实际输出
|
--- | --- |
---|
试试看。将实际输出填入表格 38-1 的右栏,并将两栏进行比较。你把一切都弄对了吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
接下来是我的系统生成的输出,以及一些注释。请记住,编译器在优化复制构造器的额外调用方面有一些余地。您可能会得到一个或两个额外的拷贝调用。
base(42) // inside make_derived()
derived(42) // finish constructing in make_derived()
copy base(42) // copy to b in call to increment()
copy base(43) // copy return value from increment to b in main
~base(43) // destroy temporary return value
base(87) // construct a in main
derived(87) // construct a in main
~derived(87) // end of main: destroy a
~base(87) // destroy a
~base(44) // destroy b
~derived(43) // destroy d
~base(43) // finish destroying d
注意引用传递(increment_reference
)没有调用任何构造器,因为没有构造任何对象。相反,引用被传递给函数,被引用的对象递增。
顺便说一下,我还没有向您展示如何重载 increment 操作符,但是您可能已经猜到了它是如何工作的(在类base
中)。减量也差不多。
访问级
在本文开始时,我建议您在基类名称前使用public
,但从未解释过为什么。现在是时候告诉你细节了。
访问级别影响继承的方式与影响成员的方式相同。当您使用struct
关键字定义一个类或者在基类名称前使用public
关键字时,公共继承就会发生。公共继承意味着派生类继承基类的每个成员,其访问级别与基类中的成员相同。除了极少数情况,这正是你想要的。
私有继承发生在你使用private
关键字的时候,当你使用class
关键字定义一个类的时候,它是默认的。私有继承保持基类的每个成员都是私有的,派生类的用户无法访问。必要时编译器仍然调用基类的构造器和析构函数,派生类仍然继承基类的所有成员。派生类可以调用基类的任何公共成员函数,但是其他人不能通过派生类调用它们。这就好像派生类将所有继承的成员重新声明为private
。私有继承允许派生类使用基类,而不需要满足替换原则。这是一种先进的技术,我建议你只有在适当的成人监督下才能尝试。
如果编译器抱怨不可访问的成员,很可能是您忘记了在类定义中包含一个public
关键字。尝试编译清单 38-5 来理解我的意思。
class base
{
public:
base(int v) : value_{v} {}
int value() const { return value_; }
private:
int value_;
};
class derived : base
{
public:
derived() : base{42} {}
};
int main()
{
base b{42};
int x{b.value()};
derived d{};
int y{d.value()};
}
Listing 38-5.Accidentally Inheriting Privately
编译器发出一个错误消息,抱怨base
是私有的或者不能从derived
访问,或者类似的事情。
程序设计式样
如果有疑问,请将数据成员和成员函数设为私有,除非您知道需要将成员设为公共。一旦一个成员成为公共接口的一部分,任何使用你的类的人都可以自由地使用这个成员,你就多了一个代码依赖。更改公共成员意味着找到并修复所有这些依赖关系。让公共接口尽可能小。如果您以后必须添加成员,您可以,但是要移除成员或将其从公共更改为私有要困难得多。每当您必须添加成员来支持公共接口时,请将支持函数和数据成员设为私有。
使用公共继承,而不是私有继承。请记住,继承的成员也成为派生类的公共接口的一部分。如果您更改了基类,您可能需要在派生类中编写额外的成员,以弥补在原始基类中但在新基类中缺少的成员。下一篇文章继续讨论派生类如何与基类一起提供重要的功能。
三十九、虚函数
衍生类很有趣,但是你不能用它们做很多事情——至少现在不能。下一步是了解 C++ 如何实现类型多态性,这一探索将带您踏上旅程。
类型多态性
回想一下 Exploration 37 中的内容,类型多态性是指 B 类型的一个变量能够采用从 B 派生的任何类的“形式”。一个明显的问题是“如何做到?”C++ 的关键是使用一个神奇的关键字在基类中声明一个成员函数,并且用一个不同的神奇的字在派生类中实现这个函数。magic 关键字告诉编译器你想调用类型多态,编译器实现多态魔术。定义一个基类引用类型的变量,并用派生类类型的对象初始化它。当您为对象调用多态函数时,编译后的代码会检查对象的实际类型,并调用该函数的派生类实现。把一个函数变成多态函数的神奇词是virtual
。派生类用override
标记。
例如,假设您希望能够使用标准(或多或少)书目格式打印库中的任何类型的作品(参见清单 38-1 )。对于书籍,我使用的格式是
作者、书名、年份。
对于期刊,我使用
标题、卷(号)、日期。
给每个类添加一个 print
成员函数,打印这些信息。因为这个函数在每个派生类中有不同的行为,所以函数是多态的,所以在print
的基类声明之前使用virtual
关键字,在每个派生类声明之后使用override
,如清单 39-1 所示。
class work
{
public:
work() = default;
work(work const&) = default;
work(std::string_view id, std::string_view title) : id_{id}, title_{title} {}
virtual ~work() {}
std::string const& id() const { return id_; }
std::string const& title() const { return title_; }
virtual void print(std::ostream&) const {}
private:
std::string id_;
std::string title_;
};
class book : public work
{
public:
book() : work{}, author_{}, pubyear_{0} {}
book(book const&) = default;
book(std::string_view id, std::string_view title, std::string_view author,
int pubyear)
: work{id, title}, author_{author}, pubyear_{pubyear}
{}
std::string const& author() const { return author_; }
int pubyear() const { return pubyear_; }
void print(std::ostream& out)
const override
{
out << author() << ", " << title() << ", " << pubyear() << ".";
}
private:
std::string author_;
int pubyear_; ///< year of publication
};
class periodical : public work
{
public:
periodical() : work{}, volume_{0}, number_{0}, date_{} {}
periodical(periodical const&) = default;
periodical(std::string_view id, std::string_view title, int volume,
int number,
std::string_view date)
: work{id, title}, volume_{volume}, number_{number}, date_{date}
{}
int volume() const { return volume_; }
int number() const { return number_; }
std::string const& date() const { return date_; }
void print(std::ostream& out)
const override
{
out << title() << ", " << volume() << '(' << number() << "), " <<
date() << ".";
}
private:
int volume_; ///< volume number
int number_; ///< issue number
std::string date_; ///< publication date
};
Listing 39-1.Adding a Polymorphic print Function to Every Class Derived from work
Tip
当在base
类中编写一个存根函数时,比如print()
,省略一个或多个参数名。编译器只需要参数类型。如果一个参数或变量没有被使用,一些编译器会警告你,即使编译器没有发出警告,对阅读你的代码的人来说,这也是一个明确的信息:参数没有被使用。
一个引用了一个work
对象的程序可以调用print
成员函数来打印该作品,并且因为print
是多态的,或者说是虚拟的,C++ 环境执行它的魔法来确保调用正确的print
,这取决于work
对象实际上是一个book
还是一个periodical
。要查看这个演示,请阅读清单 39-2 中的程序。
import <iostream>;
import <string>;
import <string_view>;
// All of Listing 39-1 belongs here
... omitted for brevity ...
void showoff(work const& w)
{
w.print(std::cout);
std::cout << '\n';
}
int main()
{
book sc{"1", "The Sun Also Crashes", "Ernest Lemmingway", 2000};
book ecpp{"2", "Exploring C++", "Ray Lischner", 2020};
periodical pop{"3", "Popular C++", 13, 42, "January 1, 2000"};
periodical today{"4", "C++ Today", 1, 1, "January 13, 1984"};
showoff(sc);
showoff(ecpp);
showoff(pop);
showoff(today);
}
Listing 39-2Calling the print Function
你期望什么输出 ?
试试看。你实际得到的输出是什么?
showoff
函数不需要知道book
或periodical
类。就其本身而言,w
是对一个work
对象的引用。您唯一可以调用的成员函数是那些在work
类中声明的函数。尽管如此,当showoff
调用print
时,如果对象的真实类型是book
或periodical
,它将调用book
的print
或periodical
的print
。
编写一个输出操作符( operator<<
),通过调用其 print
成员函数打印一个 work
对象。将您的解决方案与我的解决方案进行比较,如清单 39-3 所示。
std::ostream& operator<<(std::ostream& out, work const& w)
{
w.print(out);
return out;
}
Listing 39-3.Output Operator for Class work
编写输出操作符是完全正常的。只要确定你声明w
为推荐人。多态魔法不会发生在普通对象上,只会发生在引用上。使用这个操作符,您可以将任何从work
派生的对象写入输出流,它将使用其print
函数进行打印。
Tip
关键字const
如果存在,总是在override
之前。虽然说明符,比如virtual
,可以和函数的返回类型自由混合(即使结果很奇怪,比如int virtual long function()
),但是const
限定符和override
说明符必须遵循严格的顺序。
虚函数
由于关键字virtual
,多态函数在 C++ 中被称为虚函数。一旦一个函数被定义为虚拟的,它在每个派生类中都是如此。虚函数在派生类中必须具有相同的名称、相同的返回类型以及相同的参数数量和类型(但参数可以有不同的名称)。
实现虚函数不需要派生类。如果没有,它会像继承非虚函数一样继承基类函数。当一个派生类实现一个虚函数时,据说覆盖了该函数,因为派生类的行为覆盖了从基类继承的行为。
在派生类中,override
说明符是可选的,但有助于防止错误。如果您不小心在派生类中键入了函数名或参数,编译器可能会认为您在定义一个全新的函数。通过添加override
,您告诉编译器您打算覆盖基类中声明的虚函数。如果编译器在基类中找不到匹配的函数,它会发出一条错误消息。
添加一个类, movie
,到类库中。movie
类代表录制在磁带或光盘上的电影或影片。与book
和periodical
一样,movie
类源自work
。为了简单起见,除了从work
继承的成员之外,将movie
定义为具有整数运行时间(以分钟为单位)。暂时不要超越print
。将您的类与清单 39-4 进行比较。
class movie : public work
{
public:
movie() : work{}, runtime_{0} {}
movie(movie const&) = default;
movie(std::string_view id, std::string_view title, int runtime)
: work{id, title}, runtime_{runtime}
{}
int runtime() const { return runtime_; }
private:
int runtime_; ///< running length in minutes
};
Listing 39-4.Adding a Class movie
现在修改测试程序从清单 39-2 创建并打印一个 movie
对象。如果你愿意,你可以利用新的输出操作符,而不是调用showoff
。将您的程序与清单 39-5 进行比较。
import <iostream>;
import <string>;
import <string_view>;
// All of Listing 39-1 belongs here
// All of Listing 39-3 belongs here
// All of Listing 39-4 belongs here
... omitted for brevity ...
int main()
{
book sc{"1", "The Sun Also Crashes", "Ernest Lemmingway", 2000};
book ecpp{"2", "Exploring C++", "Ray Lischner", 2006};
periodical pop{"3", "Popular C++", 13, 42, "January 1, 2000"};
periodical today{"4", "C++ Today", 1, 1, "January 13, 1984"};
movie tr{"5", "Lord of the Token Rings", 314};
std::cout << sc << '\n';
std::cout << ecpp << '\n';
std::cout << pop << '\n';
std::cout << today << '\n';
std::cout << tr << '\n';
}
Listing 39-5.Using the New movie Class
你期望最后一行输出是什么?
试试看。你得到了什么?
因为movie
没有覆盖print
,所以它继承了基类work
的实现。在work
类中print
的定义什么也不做,所以打印tr
对象什么也不打印。
通过在电影类 中添加 print
来解决这个问题。现在你的movie
类应该看起来类似于清单 39-6 。
class movie : public work
{
public:
movie() : work{}, runtime_{0} {}
movie(movie const&) = default;
movie(std::string_view id, std::string_view title, int runtime)
: work{id, title}, runtime_{runtime}
{}
int runtime() const { return runtime_; }
void print(std::ostream& out)
const override
{
out << title() << " (" << runtime() << " min)";
}
private:
int runtime_; ///< running length in minutes
};
Listing 39-6.Adding a print Member Function to the movie Class
override
关键字在派生类中是可选的,但是强烈建议使用。一些程序员也在派生类中使用virtual
关键字。在 C++ 03 中,这提醒读者派生类函数覆盖了一个虚函数。override
说明符是在 C++ 11 中添加的,它有一个额外的特性,告诉编译器同样的事情,所以编译器可以检查你的工作,如果你犯了一个错误就可以投诉。我敦促你在任何地方都使用override
。
EVOLUTION OF A LANGUAGE
您可能会觉得奇怪,关键字virtual
出现在函数头的开头,而override
出现在结尾。你正在目睹一种语言发展过程中经常需要的妥协。
在最初的标准化之后,override
说明符被添加到语言中。添加override
说明符的一种方法是将其添加到函数说明符列表中,比如virtual
。但是给一门语言添加一个新的关键词是充满困难的。每一个使用override
作为变量或其他用户定义名称的现有程序都会崩溃。全世界的程序员将不得不检查并可能修改他们的软件,以避免这个新的关键字。
所以 C++ 标准委员会设计了一种方法来添加override
而不使其成为保留关键字。函数声明的语法将const
限定符放在一个特殊的位置。这里不允许其他标识符,所以很容易以类似于const
的方式将override
添加到成员函数的语法中,并且没有破坏现有代码的风险。
其他新的语言特性以新的方式使用现有的关键字,例如用于构造器的=default
和=delete
。但是增加了一些新的关键字,它们带来了破坏现有代码的风险。因此,委员会试图选择不太可能与现有用户选择的名字冲突的名字。在本书的后面部分,你会看到一些新关键词的例子,以及特殊语境中特殊词汇的其他新颖用法,避免将这些特殊词汇作为关键词。
参考和切片
清单 39-2 中的showoff
函数和清单 39-3 中的输出操作符将其参数声明为对const work
的引用。如果将它们改为按值传递,您认为会发生什么?
试试看。删除输出运算符声明中的&符号,如下所示:
std::ostream& operator<<(std::ostream& out, work w)
{
w.print(out);
return out;
}
运行清单 39-5 中的测试程序。实际产量是多少?
解释发生的事情。
当您通过值传递参数或将派生类对象赋给基类变量时,您会失去多态性。例如,结果不是一个book
,而是一个真正的、真实的、没有人工成分的work
——没有任何关于book
的记忆。因此,每次输出操作符调用work
的print
版本时,输出操作符都会调用它。这就是为什么程序的输出是一堆空行。当您将一个book
对象传递给输出操作符时,不仅会丢失多态性,还会丢失所有的boo
k-ness。特别是,您会丢失author_
和pubyear_
数据成员。当对象被复制到基类变量时,派生类添加的数据成员被切掉。另一种看待它的方式是:因为派生类成员被切掉了,剩下的只是一个work
对象,所以你不能有多态。同样的事情也发生在赋值上。
work w;
book nuts{"7", "C++ in a Nutshell", "Ray Lischner", 2003};
w = nuts; // slices away the author_ and pubyear_; copies only id_ and title_
编写函数时很容易避免切片(通过引用传递所有参数),但对于赋值来说就比较难处理了。你需要的管理作业的技巧在本书的后面会讲到。现在,我将专注于编写多态函数。
纯虚函数
类work
定义了print
函数,但是该函数没有做任何有用的事情。为了有用,每个派生类必须重写print
。基类的作者,比如work
,可以确保每个派生类都正确地重写一个虚函数,方法是省略函数体,代之以标记= 0
。这些标记将该函数标记为一个纯虚函数,这意味着该函数没有可继承的实现,派生类必须重写该函数。
修改 work
类,使 print
成为纯虚函数。然后删除 book
类的 print
函数,看看会发生什么。会发生什么?
编译器强制执行纯虚函数的规则。一个至少有一个纯虚函数的类被称为抽象。不能定义抽象类型的对象。修复程序。新的work
类应该看起来像清单 39-7 。
class work
{
public:
work() = default;
work(work const&) = default;
work(std::string_view id, std::string_view title) : id_(id), title_(title) {}
virtual ~work() {}
std::string const& id() const { return id_; }
std::string const& title() const { return title_; }
virtual void print(std::ostream& out) const = 0;
private:
std::string id_;
std::string title_;
};
Listing 39-7.Defining work As an Abstract Class
虚拟析构函数
虽然你现在写的大部分类都不需要析构函数,但是我想提一个重要的实现规则。任何有虚函数的类都必须声明它的析构函数也是虚的。这个规则是一个编程指南,而不是一个语义要求,所以当你违反它时,编译器不会通过发出一个消息来帮助你(尽管有些编译器可能会发出警告)。相反,你必须通过自律来执行这条规则。
当你开始编写需要析构函数的类时,我会重复这条规则。如果你自己尝试任何实验,请记住这条规则,否则你的程序可能会遇到微妙的问题——或者不那么微妙的崩溃。
下一个探索继续讨论 C++ 类型系统中的类及其关系。
四十、类和类型
C++ 的主要设计目标之一是让程序员能够定义外观和行为与内置类型几乎相同的自定义类型。类和重载操作符的结合赋予了你这种能力。这个探索更深入地研究了类型系统,以及你的类如何更好地适应 C++ 世界。
类与类型定义
假设您正在编写一个函数,根据以厘米为单位的整数身高和以千克为单位的整数体重来计算伪代谢指数(身体质量指数)。编写这样的函数没有任何困难(可以从探索 29 和 35 中复制)。为了更加清晰,您决定为height
和weight
添加typedef
s,这允许程序员定义变量来存储和操作这些值,对人类读者来说更加清晰。清单 40-1 显示了compute_bmi()
函数和相关typedef
的简单用法
import <iostream>;
using height = int;
using weight = int;
using bmi = int;
bmi compute_bmi(height h, weight w)
{
return w * 10000 / (h * h);
}
int main()
{
std::cout << "Height in centimeters: ";
height h{};
std::cin >> h;
std::cout << "Weight in kilograms: ";
weight w{};
std::cin >> w;
std::cout << "Bogus Metabolic Index = " << compute_bmi(w, h) << '\n';
}
Listing 40-1.Computing BMI
测试程序。怎么了?
如果您还没有发现它,请仔细看看对main()
中最后一行代码compute_bmi()
的调用。将自变量与函数定义中的参数进行比较。现在你明白问题了吗?
尽管height
和weight using
声明提供了额外的清晰性,我仍然犯了一个根本性的错误,颠倒了论点的顺序。在这种情况下,错误很容易被发现,因为程序很小。此外,程序的输出是如此明显地错误,以至于测试很快就揭示了问题。不过,不要太放松;不是所有的错误都这么明显。
这里的问题是,using
声明没有定义新的类型,而是为现有类型创建了一个别名。原始类型及其别名是完全可以互换的。因此,height
与int
相同,与weight
相同。因为程序员能够混淆height
和weight
,所以using
声明实际上没有多大帮助。
更有用的是创建名为height
和weight
的不同类型。作为不同的类型,您不能混淆它们,并且您可以完全控制您允许的操作。例如,将两个weight
相除会产生一个简单的、无单位的int
。将一个height
加到一个weight
上会导致编译器发出一条错误消息。清单 40-2 显示了施加这些限制的简单的height
和weight
类。
import <iostream>;
/// Height in centimeters
class height
{
public:
height(int h) : value_{h} {}
int value() const { return value_; }
private:
int value_;
};
/// Weight in kilograms
class weight
{
public:
weight(int w) : value_{w} {}
int value() const { return value_; }
private:
int value_;
};
std::istream& operator>>(std::istream& stream, height& ht)
{
int tmp;
if (stream >> tmp)
ht = height{tmp};
return stream;
}
std::istream& operator>>(std::istream& stream, weight& wt)
{
int tmp;
if (stream >> tmp)
wt = weight{tmp};
return stream;
}
/// Body-mass index
class bmi
{
public:
bmi() : value_{0} {}
bmi(height h, weight w)
: value_{(w.value() * 10000) / (h.value() * h.value())}
{}
int value() const { return value_; }
private:
int value_;
};
std::ostream& operator<<(std::ostream& out, bmi x)
{
return out << x.value();
}
int main()
{
std::cout << "Height in centimeters: ";
height h{0};
std::cin >> h;
std::cout << "Weight in kilograms: ";
weight w{0};
std::cin >> w;
std::cout << "Bogus metabolic index = " << bmi(h, w) << '\n';
}
Listing 40-2.Defining Classes for height and weight
新的类防止了错误,比如清单 40-1 中的错误,但代价是更多的代码。例如,您必须编写合适的 I/O 操作符。您还必须决定要实现哪些算术运算符。在这个简单的应用程序中,我们只实现了这个程序所需的操作符。为了在一个更大的程序中表示一个逻辑权重,您可能需要实现可以对一个权重执行的所有可能的操作,比如将两个权重相加、相减、相除等等。不要忘记比较运算符。这些函数大部分写起来都很琐碎,但是你不能忽视它们。然而,在许多应用中,通过消除潜在的误差源,这项工作将会得到很多倍的回报。
我并不是建议您抛弃不加修饰的整数和其他内置类型,用包装类来代替它们。事实上,我同意你的观点(不要问我怎么知道你在想什么),身体质量指数的例子是相当人为的。如果我正在编写一个真正的、诚实的程序来计算和管理 BMI,我会使用普通的int
变量,并依靠仔细的编码和校对来防止和检测错误。我使用包装类,比如height
和weight
,当它们添加一些主值时。一个高度和重量占主导地位的大程序会给错误提供很多机会。在这种情况下,我想使用包装类。我还可以给类添加一些错误检查,对它们可以表示的值域施加约束,或者帮助自己完成程序员的工作。尽管如此,最好从简单开始,慢慢地、小心地增加复杂性。下一节将更详细地解释要创建一个有用且有意义的自定义类,您必须实现什么行为。
值类型
height
和weight
类型是值类型的示例,即表现为普通值的类型。将它们与 I/O 流类型进行对比,后者的行为非常不同。例如,您不能复制或分配流;您必须通过引用函数来传递它们。也不能比较流或对它们执行算术运算。按照设计,值类型的行为类似于内置类型,比如int
和float
。值类型的一个重要特点是你可以将它们存储在容器中,比如vector
和map
。本节解释值类型的一般要求。
基本的指导方针是确保你的类型的行为“像一个int
”当涉及到复制、比较和执行算术时,通过使您的自定义类型在外观、行为和工作上尽可能像内置类型来避免意外。
复制
复制一个int
会产生一个新的int
,这个新的int
与原始的int
无法区分。您的自定义类型应该以同样的方式运行。
考虑一下string
的例子。string
的许多实现都是可能的。其中一些使用写入时复制来优化频繁的复制和分配。在写入时复制实现中,实际的字符串内容与string
对象是分开的。对象的副本不会复制内容,除非需要一个副本,这发生在必须修改字符串内容的时候。字符串的许多用法都是只读的,所以写时复制避免了不必要的内容复制,即使string
对象本身被频繁复制。
其他实现通过使用string
对象存储内容来优化小字符串,但是单独存储大字符串。复制小字符串速度很快,但复制大字符串速度较慢。大多数程序只使用小字符串。尽管在实现上有这些差异,但是当您复制一个string
(比如通过值将一个string
传递给一个函数)时,副本和原始的是无法区分的,就像一个int
。
通常情况下,编译器的自动复制构造器做你想做的,你不用写任何代码。尽管如此,您必须考虑复制,并确保编译器的自动(也称为隐式)复制构造器完全符合您的要求。
分配
分配对象类似于复制对象。赋值后,目标和源必须包含相同的值。赋值和复制的主要区别在于,复制是从一张白纸开始的:一个正在构建的对象。赋值从一个现有对象开始,在赋值新值之前,您可能必须清除旧值。简单类型如height
没有什么需要清理的,但是在本书的后面,你将学习如何实现更复杂的类型,如string
,这需要仔细的清理。
大多数简单的类型使用编译器的隐式赋值运算符就可以很好地工作,并且您不必编写自己的类型。尽管如此,您必须考虑这种可能性,并确保隐式赋值运算符正是您想要的。
移动的
有时候,你不想做一个精确的拷贝。我知道我写了赋值应该产生一个精确的副本,但是你可以通过让赋值将一个值从源移动到目标来打破这个规则。结果使源处于未知状态(通常为空),目标获得源的原始值。
通过调用std::move
(在<utility>
中声明)强制移动赋值:
std::string source{"string"}, target{};
target = std::move(source);
赋值后,source
处于未知但有效的状态。通常,它将为空,但您不能编写假定它为空的代码。实际上,source
的字符串内容被移到了target
中,而没有复制任何字符串内容。移动速度很快,并且与容器中存储的数据量无关。
您也可以移动初始化器中的对象,如下所示:
std::string source{"string"};
std::string target{std::move(source)};
移动适用于字符串和大多数容器,包括std::vector
。考虑清单 40-3 中的程序。
import <iostream>;
import <utility>;
import <vector>;
void print(std::vector<int> const& vector)
{
std::cout << "{ ";
for (int i : vector)
std::cout << i << ' ';
std::cout << "}\n";
}
int main()
{
std::vector<int> source{1, 2, 3 };
print(source);
std::vector<int> copy{source};
print(copy);
std::vector<int> move{std::move(source)};
print(move);
print(source);
}
Listing 40-3.Copying vs. Moving
预测清单中程序的输出 40-3 。
当我运行这个程序时,我得到了这个:
{ 1 2 3 }
{ 1 2 3 }
{ 1 2 3 }
{ }
前三行打印{ 1 2 3 }
,不出所料。但是最后一行很有趣,因为source
被移到了move
。移动对象后,唯一允许做的事情是赋值或将对象重置为已知状态,因此不能保证打印出来的结果与您预期的一样,并且您的 C++ 库可能会做一些与我使用的不同的事情。
编写移动构造器是高级的,将不得不等到本书的后面,但是您可以通过调用std::move()
来利用标准库中的移动构造器和移动赋值操作符。
比较
我用一种需要有意义的比较的方式来定义复制和赋值。如果你不能确定两个对象是否相等,你就不能验证你是否正确地复制或赋值了它们。C++ 有几种方法来检查两个对象是否相同:
-
第一种也是最明显的方法是用
==
操作符比较对象。值类型应重载此运算符。确保操作符是可传递的——也就是说,如果a == b
和b == c
,那么a == c
。确保算子是可换的,即如果a == b
,那么b == a
。最后,算子要反身:a == a
。 -
像
find
这样的标准算法通过两种方法中的一种来比较条目:用operator==
或者用调用者提供的谓词。有时,您可能想用一个定制的谓词来比较对象,例如,person
类可能有operator==
来比较每个数据成员(名字、地址等等)。),但是您想通过只检查姓氏来搜索一个包含person
对象的容器,这可以通过编写自己的比较函数来实现。自定义谓词必须遵守与==
操作符相同的传递性和自反性限制。如果将谓词用于特定的算法,该算法会以特定的方式调用谓词,因此您知道参数的顺序。你不必让你的谓词可交换,在某些情况下,你不会想这样做。 -
像
map
这样的容器按照排序的顺序存储它们的元素。一些标准算法,比如binary_search
,要求它们的输入范围是有序的。有序容器和算法使用相同的约定。默认情况下,它们使用<
操作符,但是您也可以提供自己的比较谓词。这些容器和算法从不使用==
操作符来确定两个对象是否相同。相反,它们检查等价性——也就是说,如果a < b
为假而b < a
为假,那么a
等价于b
。如果你的值类型可以排序,你应该重载
<
操作符。确保操作符是可传递的(如果a < b
和b < c
,那么a < c
)。还有,排序必须严格,也就是说,a < a
永远是假的。 -
检查等价性的容器和算法也采用一个可选的定制谓词来代替
<
操作符。定制谓词必须遵守与<
操作符相同的传递性和严格性限制。
并非所有类型都可以通过小于关系进行比较。如果你的类型不能被排序,不要实现<
操作符,但是你也必须明白你不能在map
中存储该类型的对象或者使用任何二分搜索法算法。有时,你可能想要强加一个人为的命令,仅仅是为了允许这些用途。例如,color
类型可以表示诸如red
、green
或yellow
的颜色。尽管red
或green
本身并没有将一个定义为“小于”另一个,但是您可能想要定义一个任意的顺序,这样您就可以将这些值用作map
中的键。一个直接的建议是编写一个比较函数,使用<
操作符将颜色作为整数进行比较。
另一方面,如果你有一个应该比较的值(比如rational
,你应该实现operator==
和operator<
。然后,您可以根据这两个运算符实现所有其他比较运算符。(参见探索 33 中rational
类如何做到这一点的例子。)
如果你必须在一个map
中存储无序对象,你可以使用std::unordered_map
。它的工作方式几乎与std::map
完全相同,但是它将值存储在哈希表中,而不是二叉树中。确保自定义类型可以存储在std::unordered_map
中是更高级的,直到很久以后才会涉及到。
实现一个颜色类,它将一种颜色描述为三种成分:红色、绿色和蓝色,它们是 0 到 255 之间的整数。定义一个比较函数order_color
,允许将颜色存储为map
键。为了额外加分,设计一个合适的 I/O 格式并让 I/O 操作符过载。先不要担心错误处理——例如,如果用户试图将红色设置为 1000,蓝色设置为 2000,绿色设置为 3000 会怎么样。你很快就会明白的。
将你的解决方案与我的进行比较,我的解决方案在清单 40-4 中给出。
import <iomanip>;
import <iostream>;
import <sstream>;
class color
{
public:
color() : color{0, 0, 0} {}
color(color const&) = default;
color(int r, int g, int b) : red_{r}, green_{g}, blue_{b} {}
int red() const { return red_; }
int green() const { return green_; }
int blue() const { return blue_; }
/// Because red(), green(), and blue() are supposed to be in the range [0,255],
/// it should be possible to add them together in a single long integer.
/// TODO: handle out of range
long int combined() const { return ((red() * 256L + green()) * 256) + blue(); }
private:
int red_, green_, blue_;
};
inline bool operator==(color const& a, color const& b)
{
return a.combined() == b.combined();
}
inline bool operator!=(color const& a, color const& b)
{
return not (a == b);
}
inline bool order_color(color const& a, color const& b)
{
return a.combined() < b.combined();
}
/// Write a color in HTML format: #RRGGBB.
std::ostream& operator<<(std::ostream& out, color const& c)
{
std::ostringstream tmp{};
// The hex manipulator tells a stream to write or read in hexadecimal (base 16).
// Use a temporary stream in case the out stream has its own formatting,
// such as width, adjustment.
tmp << '#' << std::hex << std::setw(6) << std::setfill('0') << c.combined();
out << tmp.str();
return out;
}
class ioflags
{
public:
/// Save the formatting flags from @p stream.
ioflags(std::basic_ios<char>& stream) : stream_{stream}, flags_{stream.flags()} {}
ioflags(ioflags const&) = delete;
/// Restore the formatting flags.
~ioflags() { stream_.flags(flags_); }
private:
std::basic_ios<char>& stream_;
std::ios_base::fmtflags flags_;
};
std::istream& operator>>(std::istream& in, color& c)
{
ioflags flags{in};
char hash{};
if (not (in >> hash))
return in;
if (hash != '#')
{
// malformed color: no leading # character
in.unget(); // return the character to the input stream
in.setstate(in.failbit); // set the failure state
return in;
}
// Read the color number, which is hexadecimal: RRGGBB.
int combined{};
in >> std::hex >> std::noskipws;
if (not (in >> combined))
return in;
// Extract the R, G, and B bytes.
int red, green, blue;
blue = combined % 256;
combined = combined / 256;
green = combined % 256;
combined = combined / 256;
red = combined % 256;
// Assign to c only after successfully reading all the color components.
c = color{red, green, blue};
return in;
}
int main()
{
color c;
while (std::cin >> c)
{
if (c == color{})
std::cout << "black\n";
else
std::cout << c << '\n';
}
}
Listing 40-4.The color Class
清单 40-3 用ioflags
职业引入了一个新的技巧。下一节将解释所有内容。
资源获取是初始化
一个被称为资源获取的编程习惯用法是初始化(RAII ),它利用了构造器、析构函数和函数返回时对象的自动销毁。简而言之,RAII 习惯用法意味着一个构造器获取一个资源:它打开一个文件,连接到一个网络,或者甚至只是从一个 I/O 流中复制一些标志。采集是对象初始化的一部分。析构函数释放资源:关闭文件,断开网络连接,或者恢复 I/O 流中任何修改过的标志。
要使用 RAII 类,您只需定义该类型的对象。仅此而已。编译器会处理剩下的事情。RAII 类的构造器接受获取其资源所需的任何参数。当周围函数返回时,RAII 对象被自动销毁,从而释放资源。就这么简单。
你甚至不用等到函数返回。在复合语句中定义一个 RAII 对象,当语句结束且控制离开复合语句时,该对象被销毁。
清单 40-4 中的ioflags
类是使用 RAII 的一个例子。它向你扔出一些新的物品;让我们一次解决一个问题:
-
std::basic_ios<char>
类是所有 I/O 流类的基类,比如istream
和ostream
。因此,ioflags
对输入和输出流的作用是一样的。 -
std::ios_base::fmtflags
类型是所有格式化标志的类型。 -
没有参数的
flags()
成员函数返回所有当前的格式化标志。 -
带有一个参数的
flags()
成员函数将所有标志设置为它的参数。
使用ioflags
的方法就是在函数或复合语句中定义一个ioflags
类型的变量,将一个流对象作为唯一的参数传递给构造器。该函数可以改变流的任何标志。在这种情况下,输入操作符用std::hex
操纵器将输入基数(或基数)设置为十六进制。输入基数与格式化标志一起存储。运算符也关闭skipws
标志。默认情况下,此标志是启用的,它指示标准输入操作符跳过初始空白。通过关闭这个标志,输入操作符不允许在英镑符号(#
)和颜色值之间有任何空白。
当输入函数返回时,ioflags
对象被销毁,它的析构函数恢复原来的格式化标志。如果没有 RAII 的魔力,operator>>
函数将不得不在所有四个返回点手动恢复标志,这是一项繁重的工作,并且容易出错。
复制一个ioflags
对象毫无意义。如果复制它,哪个对象将负责恢复标志?因此,该类删除了复制构造器。如果您不小心编写了复制ioflags
对象的代码,编译器就会抱怨。
RAII 是 C++ 中常见的编程习惯用法。你对 C++ 了解得越多,你就会越欣赏它的美丽和简单。
如您所见,我们的示例变得越来越复杂,对我来说,在一个代码清单中包含所有的示例变得越来越困难。您的下一个任务是了解如何将您的代码分成多个文件,这将使我的工作和您的工作更加容易。这项新任务的第一步是仔细研究声明、定义以及它们之间的区别。
四十一、声明和定义
探索 20 介绍了声明和定义之间的区别。这是一个很好的时机来提醒您区别,并探索类及其成员的声明和定义。
声明与定义
回想一下,声明为编译器提供了它需要的基本信息,这样你就可以在程序中使用名字。特别是,函数声明告诉编译器函数的名称、返回类型、参数类型和修饰符,比如const
和override
。
定义是一种特殊的声明,它也为实体提供了完整的实现细节。例如,函数定义包括函数声明的所有信息,以及函数体。然而,类增加了另一层复杂性,因为您可以独立于类定义本身来声明或定义类的成员。一个类定义必须声明它的所有成员。有时,您也可以将成员函数定义为类定义的一部分(这是我目前一直使用的风格),但是大多数程序员更喜欢在类内部声明成员函数,并在类定义之外单独定义成员函数。
与任何函数声明一样,成员函数声明包括返回类型(可能带有一个virtual
说明符)、函数名、函数参数和一个可选的const
或override
修饰符。如果函数是一个纯虚函数,你必须将= 0
标记作为函数声明的一部分,并且不定义函数。
除了一些例外,该函数定义与任何其他函数定义相似。定义必须跟在声明后面,也就是说,成员函数定义必须在源文件中比声明成员函数的类定义靠后。在定义中,省略virtual
和override
说明符。函数名必须以类名开头,后面是作用域操作符(::
)和函数名,这样编译器就知道你定义的是哪个成员函数。编写函数体的方式与在类定义中提供函数定义的方式相同。清单 41-1 展示了一些例子。
class rational
{
public:
rational();
rational(int num);
rational(int num, int den);
void assign(int num, int den);
int numerator() const;
int denominator() const;
rational& operator=(int num);
private:
void reduce();
int numerator_;
int denominator_;
};
rational::rational()
: rational{0}
{}
rational::rational(int num)
: numerator_{num}, denominator_{1}
{}
rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
void rational::assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
void rational::reduce()
{
assert(denominator_ != 0);
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
int rational::numerator()
const
{
return numerator_;
}
int rational::denominator()
const
{
return denominator_;
}
rational& rational::operator=(int num)
{
numerator_ = num;
denominator_ = 1;
return *this;
}
Listing 41-1.Declarations and Definitions of Member Functions
因为每个函数名都以类名开头,所以完整的构造器名是rational::rational
,成员函数名的形式是rational::numerator
、rational::operator=
等等。全名的 C++ 术语是限定名。
程序员有很多理由在类外定义成员函数。下一节将介绍函数根据定义位置的不同而有所不同的一种方式,下一篇文章将详细讨论这一主题。
内嵌函数
在 Exploration 31 中,我引入了inline
关键字,这是对编译器的一个提示,它应该通过尝试在调用点扩展函数来优化速度而不是大小。您也可以将inline
用于成员函数。事实上,对于琐碎的函数,比如返回一个数据成员而不做其他事情的函数,使用函数inline
可以提高速度和程序大小。
当您在类定义中定义一个函数时,编译器会自动添加inline
关键字。如果将定义从声明中分离出来,您仍然可以通过在函数声明或定义中添加inline
关键字来创建函数inline
。通常的做法是将inline
关键字只放在定义上,但是我建议将关键字放在两个地方,以帮助读者。
记住inline
只是一个提示。编译器不必理会这个提示。现代编译器越来越擅长自己做这些决定。
我个人的指导方针是在类定义中定义单行函数。较长的函数或阅读起来复杂的函数通常属于类定义之外。有些函数太长,不适合放在类定义中,但是足够短和简单,它们应该是inline
。组织的编码风格通常包括inline
功能的指导方针。例如,大型项目的指令可能会避开inline
功能,因为它们增加了软件组件之间的耦合。因此,inline
可能仅在逐个功能的基础上被允许,当性能测量证明其需要时。
重写清单 41-1 中的 rational 类,明智地使用 inline
函数。将您的解决方案与我的进行比较,如清单 41-2 所示。
class rational
{
public:
rational(int num) : numerator_{num}, denominator_{1} {}
rational(rational const&) = default;
inline rational(int num, int den);
void assign(int num, int den);
int numerator() const { return numerator_; }
int denominator() const { return denominator_; }
rational& operator=(int num);
private:
void reduce();
int numerator_;
int denominator_;
};
inline rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
void rational::assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
void rational::reduce()
{
assert(denominator_ != 0);
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
numerator_ = num;
denominator_ = 1;
return *this;
}
Listing 41-2.The rational Class with inline Member Functions
不要为决定哪些功能应该inline
而苦恼。当有疑问时,不要打扰。仅当性能度量显示函数被频繁调用并且函数调用开销很大时,才使用函数inline
。在所有其他方面,我认为这是一个美学和清晰的问题:我发现单行函数在类定义中更容易阅读。
变量声明和定义
普通数据成员有声明,没有定义。函数和块中的局部变量有定义,但没有单独的声明。这可能有点混乱,但不要担心,我会解开它,让它变得清晰。
命名对象的定义指示编译器留出内存来存储对象的值,并生成必要的代码来初始化对象。一些对象实际上是子对象——本身不是完整的对象(在 C++ 中,完整的对象被称为完整的对象)。子对象没有自己的定义;相反,它的内存和生存期是由包含它的完整对象决定的。这就是为什么数据成员或基类没有自己的定义。相反,具有类类型的对象的定义导致为对象的所有数据成员留出内存。因此,类定义包含数据成员的声明,但不包含定义。
定义一个局部于块的变量。定义指定了对象的类型、名称、是否为const
以及初始值(如果有的话)。你不能在没有定义局部变量的情况下声明它,但是还有其他类型的声明。
您可以将局部引用声明为局部变量的同义词。以与引用参数相同的方式将新名称声明为引用,但用现有对象对其进行初始化。如果引用是const
,你可以使用任何表达式(合适的类型)作为初始化器。对于非const
引用,你必须使用左值(还记得探索 21 中的那些吗?),如另一个变量。清单 41-3 说明了这些原则。
import <iostream>;
int main()
{
int answer{42}; // definition of a named object, also an lvalue
int& ref{answer}; // declaration of a reference named ref
ref = 10; // changes the value of answer
std::cout << answer << '\n';
int const& cent{ref * 10}; // declaration; must be const to initialize with expr
std::cout << cent << '\n';
}
Listing 41-3.Declaring and Using References
局部引用不是定义,因为没有分配内存,也没有运行初始化器。相反,引用声明为旧对象创建了一个新名称。局部引用的一个常见用途是在范围for
循环中。清单 41-4 展示了一个简单的程序,它将一系列单词读入一个向量,然后在向量中寻找最长的单词。它说明了局部引用的使用和限制。
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <vector>;
int main()
{
std::vector<std::string> data{
std::istream_iterator<std::string>(std::cin),
std::istream_iterator<std::string>()
};
// Ensure at least one string to measure.
if (data.empty()) data.emplace_back();
auto longest{ std::ranges::max(data,
[](std::string const& a, std::string const& b)
{
return a.size() < b.size();
})
};
std::cout << "Longest string is \"" << longest << "\"\n";
}
Listing 41-4.Finding the Longest String in a Data Set
如果您将string
定义为一个普通变量,而不是将其声明为一个引用,程序会运行得很好,但是它也会对data
的每个元素进行不必要的复制。在这个程序中,额外的副本是不相关的,不明显的,但在其他程序中,节省的成本可以累加。
所以如果本地引用这么俏皮,为什么longest
不也是引用呢?如果你想冒险,那就试着改变“最长”的定义作为参考。会发生什么?
请记住,引用只是其他事物的另一个名称。你必须初始化一个引用,否则它将是一个空的名字,这是不允许的。此外,如果你将一个字符串赋给一个引用,这个赋值会修改名字后面的对象。换句话说,没有办法使一个引用指向一个不同的对象。在for
循环中,string
是data
的一个元素的名字,它似乎每次都能在循环中引用不同的元素,但它这样做是因为它在每次迭代中都被重新创建。在循环体内,你无法让string
引用任何其他元素或者data
或者任何其他字符串。因为longest
在循环之外,所以无论循环迭代多少次,引用都只能有一个值。它不能被重新定义。这意味着我们必须复制最长的字符串,这似乎很浪费。幸运的是,有一个解决方案。不幸的是,它将不得不等待,因为该解决方案打开了一个巨大、丑陋、可怕的蠕虫的罐子。
静态变量
局部变量是自动的。这意味着当函数开始或进入局部块(复合语句)时,内存被分配,对象被构造。当函数返回或控制退出块时,对象被销毁,内存被回收。所有自动变量都分配在程序栈上,所以内存分配和释放是微不足道的,通常由主机平台的正常函数调用指令来处理。
记住main()
就像一个函数,遵循许多与其他函数相同的规则。因此,您在main()
中定义的变量似乎在程序的整个生命周期中都存在,但它们是自动变量,分配在栈上,编译器对待它们的方式与对待任何其他自动变量一样。
自动变量的行为允许像 RAII 这样的习惯用法(参见 Exploration 40 )并且极大地简化了典型的编程任务。尽管如此,它并不适合所有的编程任务。有时你需要一个变量的生命周期在函数调用中保持不变。例如,假设您需要一个为各种对象生成唯一标识号的函数。它从 1 开始一个串行计数器,并在每次发出 ID 时递增计数器。不管怎样,函数必须跟踪计数器的值,即使在它返回之后。清单 41-5 展示了一种方法。
int generate_id()
{
static int counter{0};
++counter;
return counter;
}
Listing 41-5.Generating Unique Identification Numbers
关键字static
通知编译器变量不是自动的,而是静态的。程序第一次调用generate_id()
时,变量counter
被初始化。内存不是自动的,也不在程序栈上分配。相反,所有的静态变量都被放在一边,所以直到程序关闭它们才会消失。当generate_id()
返回时,counter
没有被销毁,因此保留了它的值。
*写一个程序多次调用 generate_id()
,看看它能不能工作,每次调用都会产生新的值。将你的程序与我的进行比较,如清单 41-6 所示。
import <iostream>;
int generate_id()
{
static int counter{0};
++counter;
return counter;
}
int main()
{
for (int i{0}; i != 10; ++i)
std::cout << generate_id() << '\n';
}
Listing 41-6.Calling generate_id to Demonstrate Static Variables
您也可以在任何函数之外声明变量。因为它在所有函数之外,所以它不在任何块内;因此,它不可能是自动的,所以它的记忆必须是静态的。对于这样的变量,你不必使用static
关键字。重写清单 40 - 6 来声明 counter
之外的 generate_id
函数。不要使用static
关键字。向自己保证程序仍然正常工作。清单 41-7 展示了我的解决方案。
import <iostream>;
int counter;
int generate_id()
{
++counter;
return counter;
}
int main()
{
for (int i{0}; i != 10; ++i)
std::cout << generate_id() << '\n';
}
Listing 41-7.Declaring counter Outside of the generate_id Function
与自动变量不同,所有没有初始化器的静态变量都以零开始填充,即使变量有内置类型。如果该类有自定义构造器,则调用默认构造器来初始化类类型的静态变量。因此,您不必为counter
指定一个初始化器,但是如果您愿意,您可以这样做。
C++ 中的所有名字都是词汇范围的;名称仅在其作用域内可见。函数内声明的名字的作用域是包含声明的块(包括for
、if
和while
语句的语句头)。在任何函数之外声明的名字的作用域有点复杂。变量或函数的名字是全局的,在整个程序中只能用于那个实体。另一方面,您只能在声明它的源文件中使用它,从声明点到文件的结尾。(下一篇文章将更详细地介绍如何使用多个源文件。)
在所有函数之外声明的变量的通用术语是全局变量。这不是标准的 C++ 术语,但现在已经够了。
如果全局声明counter
,可以在程序的其他任何地方引用并修改,这可能不是你想要的。尽可能窄地限制每个名字的范围总是最好的。通过在generate_id
中声明counter
,你保证程序的其他部分不会意外地改变它的值。换句话说,如果只有一个函数必须访问一个静态变量,那么将变量的定义放在函数的本地。如果多个函数必须共享该变量,请全局定义该变量。
静态数据成员
关键字static
有许多用途。您可以在类中的成员声明之前使用它来声明一个静态数据成员。静态数据成员不属于该类的任何对象,而是独立于所有对象。该类类型(和派生类型)的所有对象共享该数据成员的唯一实例。静态数据成员的一个常见用途是定义有用的常量。例如,std::string
类有一个静态数据成员,npos
,大致意思是“没有位置”当成员函数不能返回一个有意义的位置时,就返回npos
,比如find
找不到它要寻找的字符串。您还可以使用静态数据成员来存储共享数据,就像共享全局静态变量一样。但是,通过使共享变量成为数据成员,您可以使用普通的类访问级别来限制对数据成员的访问。
像定义任何其他全局变量一样定义静态数据成员,但用类名限定成员名。仅在数据成员的声明中使用static
关键字,而不是在其定义中。因为静态数据成员不是对象的一部分,所以不要在构造器的初始化列表中列出它们。相反,应该像初始化普通全局变量一样初始化静态数据成员,但是要记住用类名限定成员名。使用静态数据成员时也要限定名称。清单 41-8 展示了静态数据成员的一些简单用法。
import <iostream>;
import <numeric>;
class rational {
public:
rational();
rational(int num);
rational(int num, int den);
int numerator() const { return numerator_; }
int denominator() const { return denominator_; }
// Some useful constants
static const rational zero;
static const rational one;
static const rational pi;
private:
void reduce()
{
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
int numerator_;
int denominator_;
};
rational::rational() : rational{0, 1} {}
rational::rational(int num) : numerator_{num}, denominator_{1} {}
rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
std::ostream& operator<<(std::ostream& out, rational const& r)
{
return out << r.numerator() << '/' << r.denominator();
}
const rational rational::zero{};
const rational rational::one{1};
const rational rational::pi{355, 113};
int main()
{
std::cout << "pi = " << rational::pi << '\n';
}
Listing 41-8.Declaring and Defining Static Data Members
清单 41-9 展示了一个更复杂的 ID 生成器中静态数据成员的一些例子。这个程序使用一个前缀作为它生成的 ID 的一部分,然后对每个 ID 的剩余部分使用一个串行计数器。在某些情况下,例如初始化一个整数,您可以在类定义中提供初始值。对于一个const
值,您不需要提供单独的定义,因为编译器在编译时使用该值。对于不是const
的静态数据成员,编译器还是需要为该数据成员留出内存,所以还是需要单独的定义。对于产品软件来说,每次运行使用不同的前缀是没问题的,但是会使测试变得非常复杂。因此,这个版本的程序使用固定数量 1。注释显示了预期的代码。
import <iostream>;
class generate_id
{
public:
generate_id() : counter_{0} {}
long next();
private:
short counter_;
static short prefix_;
static short const max_counter_ = 32767;
};
short generate_id::prefix_{1};
long generate_id::next()
{
if (counter_ == max_counter_)
counter_ = 0;
else
++counter_;
return static_cast<long>(prefix_) * (max_counter_ + 1) + counter_;
}
int main()
{
generate_id gen; // Create an ID generator
for (int i{0}; i != 10; ++i)
std::cout << gen.next() << '\n';
}
Listing 41-9.Using Static Data Members for an ID Generator
声明者
正如您已经看到的,您可以在一个声明中定义多个变量,如下所示:
int x{42}, y{}, z{x+y};
整个声明包含三个声明符。每个声明符声明一个名字,不管这个名字是变量、函数还是类型。大多数 C++ 程序员不会在日常对话中使用这个术语,但 C++ 专家经常使用。你必须知道官方的 C++ 术语,这样如果你需要向专家寻求帮助,你就能理解他们。
了解将声明与定义分开的最重要的原因是,您可以将定义放在一个源文件中,将声明放在另一个源文件中。下一个探索展示了如何处理多个源文件。*
四十二、模块
真正的程序很少适合一个单独的源文件,我知道你已经迫不及待了,渴望探索 C++ 如何处理组成一个单独程序的多个源文件。这一探索向您展示了基础知识。不过,有一个警告:本章讨论了 C++ 20 中的全新特性,尽管编译器开发人员正在努力实现所有的新特性,但是他们还有许多特性需要实现,所有这些艰苦的工作都需要时间。您在本章中读到的内容可能无法在您的编译器中运行,至少在将来的某个版本之前无法运行。不要担心,Exploration 43 将带你回到处理多个源文件的老式方法,这种方法从 C++ 诞生的第一天就开始了。但是现在,让我们大胆地走向未来。
介绍模块
简单地说,你通过将程序分成模块来编写一个大程序。模块是 C++ 20 处理多个源文件的方式。正如函数有声明和定义一样,模块也有接口(声明)和实现(定义)。因为一个模块可以包含多个函数、类等等,所以他们有办法将一个模块分成多个文件作为实现细节。
让我们从一个简单的例子开始。让我们在模块hello
中定义一个函数world()
。我们的main()
程序将调用那个函数。那么我们需要什么?首先,定义如清单 42-1 所示的模块。
export module hello;
import <iostream>;
export void world()
{
std::cout << "hello, world\n";
}
Listing 42-1.Writing a Module
嗯,那很简单。关键字module
通知编译器这个文件是模块的一部分。export
关键字表示这是一个接口,也就是这个文件在导出符号。特别是,该模块导出了world()
函数。该模块导入了<iostream>
,因此world()
可以完成它的工作,但这是一个实现细节。从模块外部可见的唯一信息是导出的声明,在本例中是world()
。有了这个接口,您就可以编写一个程序来导入hello
模块并调用world()
函数。清单 42-2 向您展示了如何操作。
import hello;
int main()
{
world();
}
Listing 42-2.Importing a Module
import
声明导入一个模块,这使得模块导出的每个符号在执行导入的文件中都可用。您可以使用模块导出的任何名称,就像您已经在执行导入的文件中编写了这些函数一样。在import
声明之后,hello
这个名字不再有任何意义。这意味着您不需要限定world()
函数的名称。就像你在与main()
相同的文件中定义它一样正常调用它。
一个module
声明是可选的,但是如果存在的话,它必须是文件中的第一个声明。然后是任何import
声明。导入后,你不能在文件中使用任何进一步的module
或import
声明。这些简单的限制意味着您可以在程序的其他地方使用module
和import
作为普通名称,这对于使用这些名称的现有程序来说是很好的,但是新代码不应该使用它们以避免混淆。
如果一个文件没有module
声明,就好像该文件以一个未命名的模块声明开始一样:
module;
未命名的模块也被称为全局模块。一个程序的main()
函数存在于全局模块中,任何模块都可以向全局模块贡献声明,方法是以一个未命名的模块头开始文件,接着是普通的声明,然后是一个命名的模块声明。
所有的标准库头文件(除了那些从 C 编程语言导入的)都可以作为模块导入。这就是为什么本书的代码清单用import
代表<iostream>
,用#include
代表<cassert>
。因为模块是 C++ 20 中的新特性,所以现有代码的无数行都使用#include
作为头文件。习惯看一会儿#include
。
C++ 20 标准没有将模块进一步引入到库实现中,但是您可以期待库作者开始将标准库的实现作为一套模块进行修补。例如,Microsoft Visual C++ 允许您导入std.core
来一次导入几乎整个标准库。该标准的未来版本可能会采用该名称或类似的模块名称来打包和排列标准库,但现在坚持使用该标准,并期待库开始整齐地捆绑一个功能区和一个模块。
类别和模块
让我们尝试一个更有挑战性的例子:rational
类。这很容易做到,但是引入了看不见的扭曲。清单 42-3 展示了如何定义一个包含清单 41-2 中rational
类的rat
模块。你可以自己填写剩下的细节。
export module rat1;
#include <cassert>
import <numeric>;
export class rational
{
public:
rational(int num) : numerator_{num}, denominator_{1} {}
rational(rational const&) = default;
inline rational(int num, int den);
void assign(int num, int den);
int numerator() const { return numerator_; }
int denominator() const { return denominator_; }
rational& operator=(int num);
private:
void reduce();
int numerator_;
int denominator_;
};
inline rational::rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
void rational::assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
void rational::reduce()
{
assert(denominator_ != 0);
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
numerator_ = num;
denominator_ = 1;
return *this;
}
Listing 42-3.Defining the rational Class in a Module
std::gcd()
函数在<numeric>
头文件中声明,你可以包括旧的方式#include <numeric>
,或者新的方式import <numeric>
。对于rational
类和它对std::gcd()
的使用没有区别。这主要是一种风格的选择,因为您正在编写一个模块,所以您可以生活在 C++ 现代化的前沿,您也可以导入<numeric>
头文件。
抛出一个module
声明和一个export
关键字只是第一步。回想一下 Exploration 41 中定义在类中的任何成员函数都是自动内联的。在模块外部是这样,但是在模块内部,情况就不一样了。一个模块中的函数可以内联的唯一方法是你用inline
关键字显式地实现它,你可以在清单 42-4 中看到。
export module rat2;
#include <cassert>
import <numeric>;
export class rational
{
public:
inline rational(int num) : numerator_{num}, denominator_{1} {}
inline rational(rational const&) = default;
inline rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
void assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
inline int numerator() const { return numerator_; }
inline int denominator() const { return denominator_; }
rational& operator=(int num)
{
numerator_ = num;
denominator_ = 1;
return *this;
}
private:
void reduce()
{
assert(denominator_ != 0);
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
int numerator_;
int denominator_;
};
Listing 42-4.Defining the rational Class in One Declaration in a Module
如你所见,我不仅仅添加了inline
关键词。我还将所有的成员函数移到了类定义中。熟悉 Java、Eiffel 和类似语言的读者可能对这种定义类的方式很熟悉。这个想法是将类的所有内容放在一个独立的部分中。
另外,inline
在默认的函数中是不需要的,在这个例子中是复制构造器。当编译器自动填充任何构造器或函数时,它总是自动添加inline
限定符。记住inline
只是给编译器的一个建议,而不是一个要求。
这种定义简单类的风格适合rational
,但是更复杂的类有时需要更复杂的解决方案。让我们朝这个方向迈出一步,将非内联函数隐藏在模块的不同部分。
隐藏实现
您可以将一个模块分成多个部分。最简单的划分就是接口和实现。首先删除非内联函数的定义,如清单 42-5 所示。
export module rat3;
export class rational
{
public:
inline rational(int num) : numerator_{num}, denominator_{1} {}
inline rational(rational const&) = default;
inline rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
void assign(int num, int den);
inline int numerator() const { return numerator_; }
inline int denominator() const { return denominator_; }
rational& operator=(int num);
private:
void reduce();
int numerator_;
int denominator_;
};
Listing 42-5.Defining the rational Class in a Module Interface
注意模块接口中不再需要#include
和import
了。只有reduce()
函数需要这些声明。这是分离实现有助于保持接口模块整洁的方法之一。
当编译器必须导入一个模块时,它只需要模块接口。这包括每个内联函数的定义;否则,它将无法内联编译这些函数。非内联函数的定义存在于一个单独的文件中,即模块实现。这看起来非常像一个模块接口,但是没有关键字export
。清单 42-6 显示了rat3
模块的实现。
module rat3;
#include <cassert>
import <numeric>;
void rational::assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
void rational::reduce()
{
assert(denominator_ != 0);
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
numerator_ = num;
denominator_ = 1;
return *this;
}
Listing 42-6.Writing a Module Implementation
函数定义看起来就像清单 42-3 中的一样。模块实现必须使用与模块接口相同的模块名称。模块实现中定义的任何函数、变量或类型对模块的所有用户都是隐藏的,除非模块接口导出该符号。
分离实现的主要优点是对实现的更改不会影响接口。例如,您可能希望更改 assert()来发出更有用的错误消息。可以想象,编译器将能够以这样一种方式编译单个模块,即改变 assert()调用不会影响接口的编译方式。但是通过分离实现模块,您也告诉了人类读者什么变化将影响模块的用户,什么变化对他们是隐藏的。
模块导出模块
一个模块可以导入另一个模块。这样做时,它可以将导入的模块隐藏为实现细节,或者可以公开导入的模块,就好像该模块是导出接口的一部分一样。例如,考虑清单 42-7 中的vital_stats
类(类似于清单 35-1 中的record
类,用于记录一个人的生命统计数据,包括体重指数)。
export module stats;
import <istream>;
import <ostream>;
export import <string>;
export class vital_stats
{
public:
inline vital_stats() : height_{0}, weight_{0}, bmi_{0}, sex_{'?'}, name_{}
{}
bool read(std::istream& in, int num);
void print(std::ostream& out, int threshold) const;
private:
int compute_bmi() const; ///< Return BMI, based on height_ and weight_
int height_; ///< height in centimeters
int weight_; ///< weight in kilograms
int bmi_; ///< Body-mass index
char sex_; ///< 'M' for male or 'F' for female
std::string name_; ///< Person’s name
};
Listing 42-7.The vital_stats Class to Record a Person’s Vital Statistics
因为vital_stats
类使用std::string
,所以stats
模块必须导入<string>
。同样,std:
在<istream>
中定义,std::ostream
在<ostream>
中定义。但是 stats 模块的任何用户必须能够创建一个std::string
,所以也需要使用<string>
模块,所以stats
也导出它。它不导出<istream>
和<ostream>
,因为 stats 的任何用户都会有自己的import
声明,比如<iostream>
来拾取std::cin
和std::cout
,这也导入了<istream>
和<ostream>
。所以stats
也没必要这么做。通过在stats
模块中导出所有必要的模块,您为使用它的程序员减轻了一个负担。通过不引入过多的符号,stats
避免了给消费者带来过多无关符号的负担。
例如,与导出标准头文件相比,拥有模块 A、B 和 C 的大型库通常会从部件 B 和 C 中导出部件 A。编写一组简单的模块,从模块 a
中导出const double pi = 3.14159265358979323
; b
模块导入导出 a
并且还导出一个函数 area()
,来计算一个圆的面积。模块 c
进出口 a
和出口 circumference()
来计算圆周。写一个 main()
程序来演示所有三个模块。
清单 42-8 显示模块 a;清单 42-9 显示模块 b;清单 42-10 显示模块 c;清单 42-11 显示了主程序。
module;
import b;
import c;
import <iostream>;
int main()
{
while (std::cin)
{
std::cout << "pi=" << pi << '\n';
std::cout << "Radius=";
double radius{};
if (std::cin >> radius)
{
std::cout << "Area = " << area(radius) << '\n';
std::cout << "Circumference = " << circumference(radius) << '\n';
}
}
}
Listing 42-11.Main Program Imports a, b, and c
export module c;
export import a;
export double circumference(double radius)
{
return 2.0 * pi * radius;
}
Listing 42-10.Module c Exports circumference()
export module b;
export import a;
export double area(double radius)
{
return pi * radius * radius;
}
Listing 42-9.Module b Exports area()
export module a;
export double constexpr pi = 3.14159265358979323;
Listing 42-8.Module a Exports pi
编译模块
因为模块是全新的,每个编译器供应商都以稍微不同的方式支持它们。编译目标文件库的简单时代已经一去不复返了。现在我们必须面对预编译模块、模块映射和其他复杂性。
因为每个编译器供应商做事情的方式都略有不同,所以我在这里只能提供一般性的建议。首先,检查你的工具是否支持模块。如果你给编译器传递一个特殊的选项,比如-fmodules
,它们可能是可用的。或者你可能需要等待你最喜欢的编译器的更新版本。
由于接口和实现的分离,即使两者都在同一个源文件中,编译器也需要以某种方式存储模块的接口部分,使其对任何导入程序都可用。实现部分可以被编译成一个传统的目标文件,带有一些额外的信息,但是它很可能会被单独存储。编译导入模块的文件时,编译器必须能够找到编译后的模块接口。这很棘手,因为一个模块接口实际上可以由多个部分组成。我在探索中省略了这种复杂性,因为模块已经足够复杂了,只有模块作者需要了解这种能力。模块导入程序总是获取整个模块,这意味着编译器需要能够找到并收集所有模块的片段,以便导入程序可以使用它们,也就是说,除非编译器正在编译实现的一个片段,该片段会导入实现的另一个片段。正如我所说的,将一个模块分成几个部分太复杂了,本书无法涵盖。
你最好的选择是使用一个理解模块的 IDE,让它为你处理困难。如果 IDE 与编译器紧密相连,它应该知道模块接口文件存储在哪里,以及在导入模块时如何检索它们。这是一种全新的使用 C++ 的方式,所以即使是 IDE 供应商也可能需要做一些改变来适应模块。
有可能你的编译器实现了模块,但是标准库还没有更新,所以可以导入。在更改了标准库的导入而不是其他import
声明之后,您可能能够编译并运行本探索中的示例。
既然你已经看到了 C++ 编程的未来,我很遗憾地告诉你,数十亿行 C++ 代码是在没有模块的帮助下编写的,而你,我的朋友,将不得不维护这数十亿行代码中的一小部分。您需要学习如何编写代码,而不是作为模块,而是仅仅作为#include
文件,这是下一篇文章的主题。
四十三、老式的“模块”
模块是未来的发展方向,但是在未来到来之前,我们会被#include
文件所束缚。目前有数十亿行 C++ 代码使用#include
文件,所以你需要知道它们是如何工作的。通过一点训练,您仍然可以将接口与实现分开,并实现模块提供的大部分功能。
接口作为标题
基本原则是你可以在任何源文件中定义任何函数或全局对象。编译器不关心哪个文件包含什么。只要它对需要的每个名字都有一个声明,它就能把一个源文件编译成一个目标文件。(在术语趋同的不幸情况下,在 C++ 程序中,对象文件与对象无关。)要创建最终的程序,你必须把所有的目标文件链接在一起。链接器不关心哪个文件包含哪个定义;它只需为编译器生成的每个名称引用找到一个定义。
前面的探索将rational
类呈现为一个接口(清单 42-5 )和一个实现(清单 42-6 )。让我们重写程序,将rational
类接口放在一个名为rational.hpp
的文件中,将实现放在另一个名为rational.cpp
的文件中。清单 43-1 显示了rational.hpp
文件。
#ifndef RATIONAL_HPP_
#define RATIONAL_HPP_
#include <iosfwd>
class rational
{
public:
inline rational(int num) : numerator_{num}, denominator_{1} {}
inline rational(rational const&) = default;
inline rational(int num, int den)
: numerator_{num}, denominator_{den}
{
reduce();
}
void assign(int num, int den);
inline int numerator() const { return numerator_; }
inline int denominator() const { return denominator_; }
rational& operator=(int num);
private:
void reduce();
int numerator_;
int denominator_;
};
std::ostream& operator<<(std::ostream&, rational const&);
#endif // RATIONAL_HPP_
Listing 43-1.The Interface Header for rational in rational.hpp
清单 43-2 显示rational.cpp
。
#include "rational.hpp"
#include <cassert>
#include <numeric>
#include <ostream>
void rational::assign(int num, int den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
void rational::reduce()
{
assert(denominator_ != 0);
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
rational& rational::operator=(int num)
{
numerator_ = num;
denominator_ = 1;
return *this;
}
std::ostream& operator<<(std::ostream& stream, rational const& r)
{
return stream << r.numerator() << '/' << r.denominator();
}
Listing 43-2.The rational Implementation in rational.cpp
要使用 rational 类,您必须#include "rational.hpp"
,如清单 43-3 所示。
#include <iostream>
#include "rational.hpp"
int main()
{
rational pi{3927, 1250};
std::cout << "pi approximately equals " << pi << '\n';
}
Listing 43-3.The main Function Using the rational Class in the main.cpp File
现在编译main.cpp
和rational.cpp
,然后将它们链接在一起,产生一个工作的 C++ 程序。如果两个源文件是同一个项目的一部分,IDE 会为您处理细节。如果使用命令行工具,可以调用相同的编译器,但是不要在命令行中列出源文件名,而只列出目标文件名。或者,通过在一次编译中列出所有源文件名,可以同时编译和链接。
这是基本的想法,但是细节,当然,有点棘手。在接下来的探索中,我们将仔细研究这些细节。
内嵌或非内嵌
因为清单 42-1 不是一个模块,在类声明中定义的函数是隐式内联的。但是我还是用关键字inline
声明了它们。这是一个提醒读者这些函数是内联的好习惯。它还提倡一种混合的编程风格。
尽管跳跃到未来并 100%拥抱模块会很有趣,但我们必须维护现有代码并编写新代码。我们希望向前发展,而不是将我们的代码锁定在过去,所以理想的情况是编写可以在两个世界中都存在的代码。事实证明,使用模块和头文件很容易做到这一点。第一步是明确使用inline
关键字。现在类定义在一个模块和一个头中工作。然后从头部创建一个模块,如清单 43-4 所示。
export module rat;
export {
#include "rational.hpp"
}
Listing 43-4.Creating a Module from a Header
同样,使用模块使编码变得容易。export
关键字可以应用于大括号括起来的块中的所有声明。在这种情况下,#include
d 头包含一个类声明,但是它可以包含更多。
引号和括号
所有标准库的#include
指令都使用尖括号,比如<iostream>
,但是#include "rational.hpp"
使用双引号。不同之处在于,尽管一些第三方库也推荐使用尖括号,但您应该只对标准库和系统头使用尖括号。其他的都用双引号。C++ 标准故意含糊不清,建议系统提供的头文件使用尖括号,其他头文件使用引号。关于命名他们的库文件以及它们是否需要尖括号或双引号,附加库的供应商都采取了不同的方法。
对于你自己的文件,重要的方面是编译器必须能够找到你所有的#include
文件。最简单的方法是将它们保存在与源文件相同的目录或文件夹中。随着您的项目变得更大更复杂,您可能会想要将所有的#include
文件移动到一个单独的区域。在这种情况下,您必须查阅您的编译器文档,以了解如何通知编译器有关该独立区域的信息。g++
和其他 UNIX 和类 UNIX 命令行工具的用户通常使用-I
(大写字母 I )选项。微软的命令行编译器使用/I
。ide 有一个项目选项,你可以在列表中添加一个目录或文件夹来搜索#include
文件。
对于许多编译器来说,尖括号和引号之间的唯一区别是它在哪里寻找文件。一些编译器还有其他特定于该编译器的差异。
在一个源文件中,我喜欢把所有的标准头文件按字母顺序排列在一起,先列出它们,然后是特定于程序的#include
文件(也是按字母顺序)。这种组织方式让我很容易确定一个源文件#include
是否是一个特殊的头文件,并帮助我根据需要添加或删除#include
指令。
包括警卫
模块和#include
文件的一个非常重要的区别是,一个模块可以多次导入而不会产生不良影响。但是多次使用同一个文件可能会重复该文件中的所有声明,这是不允许的。清单 43-1 用#ifndef RATIONAL_HPP_
防止这种可能的错误。指令#ifndef
是“if not defined”的缩写,所以第一行测试RATIONAL_HPP_
是否未定义,这是没有定义的。第二行开始定义它。一个#endif
关闭文件末尾的条件。如果同一个文件再次被#include
d,现在RATIONAL_HPP_
被定义,那么#ifndef
为假,整个文件被跳过,一直到#endif
。众所周知,这个 include guard 是头文件中常见的习惯用法。模块中不需要它,但它是无害的。(请记住,不要以下划线开始保护名称。我使用了一个尾部下划线来确保该名称不会与头文件可能声明的任何真实名称冲突。)
远期申报
<istream>
头包含std::istream
的完整声明和其他相关声明,< ostream
>声明std::ostream
。这些是大标题中的大类。有时候,你不需要完整的类声明。例如,在接口中声明输入或输出函数需要通知编译器std::istream
和std::ostream
是类,但是编译器只需要知道实现文件中完整的类定义。
头文件<iosfwd>
是一个小头文件,它声明了名字std::istream
、std::ostream
等等,但没有提供完整的类声明。因此,通过将<istream>
和<ostream>
改为<iosfwd>
,可以减少任何包含头文件的文件的编译时间。
您可以对您自己的类做同样的事情,在关键字class
之后声明类名,不要用其他的东西来描述这个类:
class rational;
这就是所谓的转发声明。当编译器必须知道一个名字是一个类,但是不需要知道这个类或者这个类的任何成员的大小时,你可以使用前向声明。一个常见的情况是将一个类单独用作引用函数参数。
如果你的头使用了<iosfwd>
或者其他的转发声明,确保在。cpp 源文件。
外部变量
全局变量通常不是一个好主意,但是全局常量非常有用。如果你定义了一个constexpr
常量,你可以把它放在一个头中,不用再担心它。但并不是所有的常量对象都可以是constexpr
。如果你需要定义一个全局常量并且不能使它成为constexpr
,你需要在一个头文件中声明它,并且在一个单独的源文件中定义它,你把它和你程序的其余部分链接起来。使用extern
关键字在头中声明常量。将全局常量的声明和定义分开的另一个原因是,您可能需要更改常量的值,但不想重新编译整个程序。
例如,假设您需要定义一些全局常量,以便在更大的程序中使用。程序名和全局版本号不会经常改变或者无论如何都会在程序重新构建时改变,所以可以将它们设为constexpr
并在globals.hpp
中声明。但是您还想声明一个名为 credits 的字符串,它包含整个项目的引用和致谢。你不想仅仅因为别人在字符串上加了一个学分就重新构建你的组件。所以信用的定义放在一个单独的globals.cpp
文件中。从编写 globals.hpp 开始,使用 include guards,对有值的 globals 使用 constexpr,对无值的 globals 使用 extern。将你的文件与清单 43-5 进行比较。
#ifndef GLOBALS_HPP_
#define GLOBALS_HPP_
#include <string_view>
constexpr std::string_view program_name{ "The Ultimate Program" };
constexpr std::string_view program_version{ "1.0" };
extern const std::string_view program_credits;
#endif
Listing 43-5.Simple Header for Global Constants
项目中的一个源文件必须定义program_credits
。将文件命名为globals.cpp
。写 globals.cpp
。将您的文件与清单 43-6 进行比较。
#include "globals.hpp"
std::string_view const program_credits{
"Ray Lischner\n"
"Jane Doe\n"
"A. Nony Mouse\n"
};
Listing 43-6.Definitions of Global Constants
发明一个程序来测试全局变量。将您的main.cpp
与globals.cpp
链接,创建程序。清单 43-7 展示了这样一个程序的例子。
#include <iostream>
#include "globals.hpp"
int main()
{
std::cout << "Welcome to " << program_name << ' ' << program_version << '\n';
std::cout << program_credits;
}
Listing 43-7.A Trivial Demonstration of globals.hpp
一定义规则
编译器强制执行一条规则,允许每个源文件有一个类、函数或对象的定义。另一个规则是在整个程序中你只能有一个函数或者全局对象的定义。如果所有源文件中的定义都相同,则可以在多个源文件中定义一个类。
内联函数遵循与普通函数不同的规则。您可以在多个源文件中定义一个内联函数。每个源文件只能有一个内联函数的定义,并且程序中的每个定义都必须相同。
这些规则统称为一个定义规则(ODR)。
编译器在单个源文件中强制执行 ODR。然而,该标准不需要编译器或链接器来检测跨越多个源文件的任何 ODR 违规。如果你犯了这样的错误,问题是你自己去发现和解决的。
假设你在维护一个程序,程序的一部分是清单 43-8 所示的头文件。
#ifndef POINT_HPP_
#define POINT_HPP_
class point
{
public:
point() : point{0, 0} {}
point(int x, int y) : x_{x}, y_{y} {}
int x() const { return x_; }
int y() const { return y_; }
private:
int y_, x_;
};
#endif // POINT_HPP_
Listing 43-8.The Original point.hpp File
这个程序运行得很好。然而,有一天,您升级了编译器版本,当重新编译程序时,新的编译器发出了一个警告,如下所示,这是您从未见过的:
point.hpp: In constructor 'point::point()':
point.hpp:13: warning: 'point::x_' will be initialized after
point.hpp:13: warning: 'int point::y_'
point.hpp:8: warning: when initialized here
问题是数据成员声明的顺序不同于构造器初始化列表中数据成员的顺序。这是一个小错误,但是在更复杂的类中会导致混乱,甚至更糟。确保订单一致是个好主意。假设您决定通过对数据成员重新排序来解决这个问题。
然后你重新编译程序,但是程序以神秘的方式失败了。您的回归测试有些通过了,有些失败了,包括过去从未失败过的琐碎测试。
哪里出了问题?
由于信息如此有限,您无法确定哪里出错了,但最有可能的情况是重新编译未能捕获所有的源文件。程序的某些部分(不一定是失败的部分)仍然使用旧的point
类的定义,而程序的其他部分使用新的定义。该程序未能遵守 ODR,导致未定义的行为。具体来说,当程序将一个point
对象从程序的一部分传递到另一部分时,程序的一部分在x_
中存储一个值,另一部分读取与y_
相同的数据成员。
这只是一个小小的例子,说明违反 ODR 教的行为可能既微妙又可怕。通过确保所有的类定义都在各自的头文件中,并且每次修改头文件时都要重新编译所有相关的源文件,可以避免大多数意外的 ODR 违规。
模块不会让 ODR 问题消失,但是它们会大大降低你遇到问题的可能性。因为模块是编译器知道的不同实体,并且有它们自己的语义,所以编译器可以比使用#include
头文件做更多的错误检查,头文件只是文件,不携带额外的语义信息。因此,当使用模块时,编译器可能会告诉您,该实现是用不同版本的接口编译的,或者自上次编译该实现以来,某个特定类的接口发生了变化。
Tip
如果您可以编写自己的模块,我建议您这样做,即使您的工具还不完全支持标准库模块。从预发布版本来看,看起来导入标准库模块,比如import <iostream>
,可能是最后一个要实现的模块方面。只是不要让那阻止你写你自己的。
既然您已经拥有了开始编写一些严肃程序所需的工具,那么是时候开始学习一些更高级的技术了。下一篇文章介绍了函数对象——一种使用标准算法的强大技术。
四十四、函数对象
在 C++ 程序中,类有很多很多的用途。这种探索引入了一种用类来代替函数的强大方法。这种编程风格对于标准算法特别有用。
函数调用运算符
第一步是看一看一个不寻常的“操作符”,即函数调用操作符,它让一个对象表现为一个函数。像重载其他运算符一样重载该运算符。它的名字叫operator()
。它接受任意数量的参数,可以有任意的返回类型。清单 44-1 展示了generate_id
类的另一个迭代(最后一次出现在清单 41-5 中),这次用函数调用操作符替换了next()
成员函数。在这种情况下,函数没有参数,因此第一组空括号是操作符名称,第二组是空参数列表。
export module generate_id;
/// Class for generating a unique ID number.
export class generate_id
{
public:
generate_id() : counter_{0} {}
long operator()();
private:
short counter_;
static short prefix_;
static short constexpr max_counter_{32767};
};
Listing 44-1.Rewriting generate_id to Use the Function Call Operator
清单 44-2 显示了函数调用操作符的实现(还有prefix_
,它也需要一个定义)。
module generate_id;
short generate_id::prefix_{1};
long generate_id::operator()()
{
if (counter_ == max_counter_)
counter_ = 0;
else
++counter_;
return static_cast<long>(prefix_) * (max_counter_ + 1) + counter_;
}
Listing 44-2.Implementation of the generate_id Function Call Operator
为了使用函数调用操作符,必须首先声明一个类类型的对象,然后像使用函数名一样使用对象名。像传递普通函数一样传递参数给这个对象。编译器将对象名的使用视为一个函数,并调用函数调用操作符。清单 44-3 显示了一个使用generate_id
函数调用操作符为新的库作品生成 ID 代码的示例程序。(还记得探索 39 的work
班吗?将work
及其派生类收集到一个模块文件中,并添加必要的import
声明。调用模块library
。或者从该书的网站下载完整的library.cpp
。)假设int_to_id
将一个整数标识转换成work
要求的字符串格式,比如它调用std::to_string()
。
import <iostream>;
import generate_id;
import library;
bool get_movie(std::string& title, int& runtime)
{
std::cout << "Movie title: ";
if (not std::getline(std::cin, title))
return false;
std::cout << "Runtime (minutes): ";
if (not (std::cin >> runtime))
return false;
return true;
}
int main()
{
generate_id gen{}; // Create an ID generator
std::string title{};
int runtime{};
while (get_movie(title, runtime))
{
movie m(int_to_id(gen()), title, runtime);
std::cout << "new movie: " << m << '\n';
}
}
Listing 44-3.Using a generate_id Object’s Function Call Operator
函数对象
函数对象或函子是重载函数调用运算符的类的类类型对象。非正式地,程序员有时也将类称为“函数对象”,理解为实际的函数对象是用该类类型定义的变量。
C++ 03 程序经常使用函子,但是 C++ 11 有 lambdas,它们更容易读写。(回忆《探索》中的 lambdas23?)那么函子提供了 lambdas 所缺乏的什么呢?要回答这个问题,请考虑以下问题。
假设你需要一个包含递增值整数的向量。例如,大小为 10 的向量将包含值 1,2,3,…,8,9,10。std::generate
算法接受一个迭代器范围,并为该范围的每个元素调用一个函数或仿函数,将仿函数的结果分配给连续的元素。写一个λ来作为 std::generate
的最终参数。(通过将所需大小传递给构造器,构造一个特定大小的向量。记得用括号代替花括号来调用正确的构造器。)在清单 44-4 中将您的解决方案与我的进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
int main()
{
std::vector<int> vec(10);
int state;
std::ranges::generate(vec, [&state]() { return ++state; });
// Print the resulting integers, one per line.
std::ranges::copy(vec, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 44-4.A Program for Generating Successive Integers
好的,这很简单,但是解决方案不是很通用。lambda 不能在其他任何地方重用。它需要state
变量,每个状态变量只能有一个这样的 lambda。你能想出一种方法来写一个 lambda,这样你就可以有不止一个生成器,每个都有自己的状态吗?这对于 lambdas 来说很难做到,但是对于仿函数来说很容易。写一个函子类来生成连续的整数,所以这个函子可以和 generate
算法一起使用。给班级取名sequence
。构造器有两个参数:第一个指定序列的初始值,第二个是增量。每次调用函数调用操作符时,它都返回生成器值,然后递增该值,这将是下一次调用函数调用操作符时返回的值。清单 44-5 显示了主程序。在单独的模块中编写您的解决方案,sequence
。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
import sequence;
int main()
{
int size{};
std::cout << "How many integers do you want? ";
std::cin >> size;
int first{};
std::cout << "What is the first integer? ";
std::cin >> first;
int step{};
std::cout << "What is the interval between successive integers? ";
std::cin >> step;
std::vector<int> data(size);
// Generate the integers to fill the vector.
std::ranges::generate(data, sequence(first, step));
// Print the resulting integers, one per line.
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 44-5.Another Program for Generating Successive Integers
将您的解决方案与我的进行比较,如清单 44-6 所示。
export module sequence;
/// Generate a sequence of integers.
export class sequence
{
public:
/// Construct the functor.
/// @param start the first value the generator returns
/// @param step increment the value by this much for each call
inline sequence(int start, int step ) : value_{start}, step_{step} {}
inline sequence(int start) : sequence{start, 1} {}
inline sequence() : sequence{0} {}
/// Return the current value in the sequence, and increment the value.
int operator()()
{
int result(value_);
value_ = value_ + step_;
return result;
}
private:
int value_;
int const step_;
};
Listing 44-6.The sequence Module
generate
算法有一个伙伴generate_n
,它用迭代器指定输入范围,迭代器指定范围的开始,整数指定范围的大小。下一篇文章将研究这种算法和其他几种有用的算法。
四十五、有用的算法
标准库包括一套函数,该库称之为算法,以简化许多涉及在数据范围内重复应用运算的编程任务。数据可以是对象的容器、容器的一部分、从输入流中读取的值,或者可以用迭代器表达的任何其他对象序列。我在适当的时候介绍了一些算法。这一探索对许多最有用的算法进行了更深入的研究。
本探索中的所有算法都在<algorithm>
模块中定义。
范围和迭代器
到目前为止使用的大多数算法都使用范围,但是正如你在 Exploration 23 中了解到的,有时使用迭代器是有用的。在大多数情况下,相同的算法在两种风格中都可用。但是,因为范围算法在大多数编程情况下更容易使用,所以本文主要讨论范围算法。请记住,相同的算法通常也可以在迭代器中使用。有些仅以迭代器的形式存在。
范围风格的算法位于std::ranges
名称空间中,这有助于保持算法的组织性。例如,下面是两种形式的copy
算法,都将data
的内容复制到标准输出:
std::copy(data.begin(), data.end(), std::ostream_iterator<int>(std::cout));
std::ranges::copy(data, std::ostream_iterator<int>(std::cout));
助手迭代器,比如std::ostream_iterator
,在<iterator>
模块中声明。范围助手,如std::ranges::istream_view
,在<ranges>
模块中声明。
搜索
标准算法包括多种搜索方式,分为两大类:线性和二进制。线性搜索检查一个范围内的每个元素,从第一个元素开始,继续到后续元素,直到到达末尾(或者搜索因成功而结束)。二进制搜索要求元素按照升序排序,使用<
操作符,或者根据自定义谓词,即返回布尔结果的函数、仿函数或 lambda。
线性搜索算法
最基本的线性搜索是find
函数。它在一系列读迭代器中搜索一个值。它返回一个迭代器,该迭代器引用范围中的第一个匹配元素。如果find
找不到匹配,它返回结束迭代器的副本。清单 45-1 显示了它的使用示例。该程序将整数读入一个向量,搜索值 42,如果找到,则将该元素更改为 0。
import <algorithm>;
import <iostream>;
import data;
int main()
{
intvector data{};
read_data(data);
write_data(data);
if(auto iter{std::ranges::find(data, 42)}; iter == data.end())
std::cout << "Value 42 not found\n";
else
{
*iter = 0;
std::cout << "Value 42 changed to 0:\n";
write_data(data);
}
}
Listing 45-1.Searching for an Integer
清单 45-2 显示了data
模块,它提供了一些处理整数向量的工具。本探索中的大多数示例将import
本模块。
export module data;
import <algorithm>;
import <iostream>;
import <iterator>;
export import <vector>;
/// Convenient shorthand for a vector of integers.
export using intvector = std::vector<int>;
/// Read a series of integers from the standard input into @p data,
/// overwriting @p data in the process.
/// @param[in,out] data a vector of integers
export void read_data(intvector& data)
{
data.clear();
data.insert(data.begin(), std::istream_iterator<int>(std::cin),
std::istream_iterator<int>());
}
/// Write a vector of integers to the standard output. Write all values on one
/// line, separated by single space characters, and surrounded by curly braces,
/// e.g., { 1 2 3 }.
/// @param data a vector of integers
export void write_data(intvector const& data)
{
std::cout << "{ ";
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, " "));
std::cout << "}\n";
}
Listing 45-2.The data Module to Support Integer Data
与find
算法相伴的是find_if
。find_if
不是搜索匹配值,而是采用谓词函数或函数对象(从现在开始,我将编写函子来表示自由函数、函数对象或 lambda)。它为范围内的每个元素调用仿函数,直到仿函数返回true
(或者任何可以自动转换为true
的值,比如非零数值)。如果仿函数从不返回 true,find_if
返回结束迭代器。
每种搜索算法都有两种形式。第一个使用操作符比较条目(线性搜索使用==
,二进制搜索使用<
)。第二种形式使用调用方提供的函子,而不是运算符。对于大多数算法,函子是算法的附加参数,因此编译器可以区分这两种形式。在少数情况下,两种形式采用相同数量的参数,并且库使用不同的名称,因为编译器无法区分这两种形式。在这些情况下,函子形式的名称中添加了_if
,例如find
和find_if
。
假设你想搜索一个整数向量,不是一个单一的值,而是在某个范围内的任何值。您可以编写一个自定义谓词来测试硬编码的范围,但更有用的解决方案是编写一个通用的函子来比较整数和任何范围。通过将范围限制作为参数提供给构造器来使用该函子。这是自由函数、函数对象还是 lambda 的最佳实现? ________________________ 因为必须存储状态,所以我推荐写一个函子。
当您需要搜索特定值时,lambda 很好,但是如果您想要编写一个可以存储限制的通用比较器,仿函数更容易。写出了 intrange
的函子。构造器接受两个int
参数。函数调用操作符接受一个int
参数。如果参数在构造器中指定的包含范围内,则返回 true 如果参数不在该范围内,则返回 false。
清单 45-3 展示了我对intrange
的实现。这个范围包括低端和高端,这不同于使用半开范围的 C++ 约定。但这是确保一个范围可以跨越整组整数的最简单的方法。在半开范围中,下限和上限具有相同值的范围是表示空范围的典型方式。对于intrange
,当high < low
出现时,出现一个空范围。
export module intrange;
import <algorithm>;
/// Check whether an integer lies within an inclusive range.
export class intrange
{
public:
/// Construct an integer range.
/// If the parameters are in the wrong order,
/// swap them to the right order.
/// @param low the lower bound of the inclusive range
/// @param high the upper bound of the inclusive range
inline intrange(int low, int high)
: low_{low}, high_{high}
{}
/// Check whether a value lies within the inclusive range.
/// @param test the value to test
inline bool operator()(int test)
const
{
return test >= low_ and test <= high_;
}
private:
int const low_;
int const high_;
};
Listing 45-3.Functor intrange to Generate Integers in a Certain Range
编写一个测试程序,它从标准输入中读取整数,然后使用find_if
和intrange
找到位于范围【10,20】内的第一个值。在清单 45-4 中将你的解决方案与我的进行比较。
import <algorithm>;
import <iostream>;
import <ranges>;
import data;
import intrange;
int main()
{
intvector data{};
read_data(data);
write_data(data);
if (auto iter{std::ranges::find_if(data, intrange{10, 20})}; iter == data.end())
std::cout << "No values in [10,20] found\n";
else
std::cout << "Value " << *iter << " in range [10,20].\n";
}
Listing 45-4.Using find_if and intrange to Find an Integer That Lies Within a Range
以下几个示例生成随机数据并将算法应用于数据。标准库有一个丰富、复杂的库来生成伪随机数。这个库的细节超出了本书的范围。只有数学冒险家才应该破解<random>
模块的细节。为了方便起见,清单 45-5 给出了randomint
模块,该模块定义了randomint
类,该类在调用者提供的范围内生成随机整数。
export module randomint;
import <algorithm>;
import <random>;
/// Generate uniformly distributed random integers in a range.
export class randomint
{
public:
using result_type = std::default_random_engine::result_type;
/// Construct a random-number generator to produce numbers in the range [`low`, `high`].
/// If @p low > @p high the values are reversed.
randomint(result_type low, result_type high)
: prng_{},
distribution_{std::min(low, high), std::max(low, high)}
{}
/// Generate the next random number generator.
result_type operator()()
{
return distribution_(prng_);
}
private:
// implementation-defined pseudo-random-number generator
std::default_random_engine prng_;
// Map random numbers to a uniform distribution.
std::uniform_int_distribution<result_type> distribution_;
};
Listing 45-5.Generating Random Integers
search
函数类似于find
,除了它搜索匹配的子范围。也就是说,您提供了一个搜索范围和一个要查找的值范围。search
算法寻找等于整个匹配范围的元素序列的第一个匹配项,并返回一个子范围,它实际上是一对迭代器,指向找到第一个匹配项的搜索范围。如果没有找到,则子范围为空,这在if
语句中被评估为假。清单 45-6 显示了一个愚蠢的程序,它生成一个 0 到 9 范围内的随机整数的大向量,然后搜索与π的前四位数字匹配的子范围。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
import data;
import randomint;
int main()
{
intvector pi{ 3, 1, 4, 1 };
intvector data(10000, 0);
// The randomint functor generates random numbers in the range [0, 9].
std::ranges::generate(data, randomint{0, 9});
auto match{std::ranges::search(data, pi)};
if (not match)
std::cout << "The integer range does not contain the digits of pi.\n";
else
{
std::cout << "Easy as pi: ";
std::ranges::copy(match, std::ostream_iterator<int>(std::cout, " "));
std::cout << '\n';
}
}
Listing 45-6.Finding a Subrange That Matches the First Four Digits of π
其他有用的线性函数包括count
,它接受一个范围和值,并返回该值在该范围内出现的次数。它的对应物count_if
接受一个谓词而不是一个值,并返回谓词返回 true 的次数。
另外三种算法有一个共同的模式。它们对一个范围内的每个元素应用一个谓词,并返回一个bool
:
-
如果
predicate(element)
为range
中的每个元素返回true
,则all_of(range, predicate)
返回true
。 -
对于
range
中的至少一个元素,any_of(range, predicate)
返回true
如果predicate
(element)
返回true
)。 -
如果
predicate(element)
为range
中的每个元素返回false
,则none_of(range, predicate)
返回true
。
min
、max
和minmax
算法存在于迭代器世界中,同样位于值域中。在 C++ 20 之前,min()
函数比较两个值,返回较小的一个;min_element
算法采用两个迭代器,找到最小值的位置。现在,std::ranges::min()
函数返回一个范围的最小值,std::ranges::min_element()
也返回一个范围的最小值。max()
同上。您可以猜测minmax
返回了什么:一个pair
迭代器,用于该范围内的最小值和最大值。这三个都是常见的重载形式:一个使用<
操作符,另一个接受一个比较谓词的附加参数。
二分搜索法算法
map
容器按排序顺序存储其元素,因此您可以使用任何二分搜索法算法,但是map
也有成员函数,可以利用对map
内部结构的访问,因此提供了改进的性能。因此,二分搜索法算法通常用于顺序容器,比如vector
,当你知道它们包含排序的数据时。如果输入范围没有正确排序,结果是不确定的:您可能得到错误的答案;程序可能会崩溃;或者更糟糕的事情会发生。
binary_search
函数只是测试一个排序的范围是否包含一个特定的值。默认情况下,只使用<
运算符来比较值。另一种形式的binary_search
将比较函子作为执行比较的附加参数。
WHAT’S IN A NAME?
find
函数对单个项目执行线性搜索。search
函数对匹配的一系列项目执行线性搜索。那么为什么binary_search
不叫binary_find
?另一方面,find_end
搜索一系列值中最右边的匹配,那么为什么不叫它search_end
?equal
功能与equal_range
完全不同,尽管它们的名称相似。
C++ 标准委员会尽最大努力为算法名称应用统一的规则,例如将_if
附加到采用仿函数参数但不能重载的函数上,但它面临着一些名称的历史约束。这对你来说意味着你必须将一份推荐信放在手边。不要根据名字来判断一个函数,但是在你决定是否使用这个函数之前,要阅读这个函数做什么和如何做的描述。
lower_bound
函数类似于binary_search
,除了它仅以迭代器的形式存在。它需要两个迭代器来界定一个输入范围,并返回一个指向该范围某处的迭代器。返回的迭代器指向值的第一个匹配项,或者如果您想将值插入到范围中并保持值的排序顺序,它指向值所属的位置。upper_bound
函数类似于lower_bound
,除了它返回一个迭代器,指向最后一个可以插入值并保持排序的位置。如果找到了该值,这意味着upper_bound
指向该值在范围内最后一次出现后的位置。换句话说,范围[ lower_bound
,upper_bound
]是该值在排序范围内每次出现的子范围。与任何范围一样,如果lower_bound == upper_bound
,结果范围为空,这意味着该值不在搜索范围内。
清单 45-7 展示了一种排列排序输入的缓慢方法。把整数读入一个向量然后调用sort()
更快。这只是使用lower_bound()
和upper_bound()
的一个例子。额外的好处是,清单 45-7 只在向量中不存在的时候插入一个值。
import <algorithm>;
import <iostream>;
import <ranges>;
import data;
int main()
{
intvector data{};
int value;
while (std::cin >> value)
{
auto lb{std::lower_bound(data.begin(), data.end(), value)};
auto ub{std::upper_bound(data.begin(), data.end(), value)};
if (lb == ub)
// Not in data, so insert.
data.insert(ub, value);
// else value is already in the vector
}
write_data(data);
}
Listing 45-7.Using lower_bound to Create a Sorted Vector
为了更好地理解lower_bound
和upper_bound
到底是如何工作的,写一个测试程序是有帮助的。程序可以从用户那里读入一些整数到一个向量中,对向量进行排序,然后使用lower_bound
和upper_bound
测试一些值。为了帮助您准确理解这些函数返回的内容,调用distance
函数来确定迭代器在向量中的位置,如下所示:
auto iter{std::lower_bound(data.begin(), data.end(), 42)};
std::cout << "Index of 42 is " << std::distance(data.begin(), iter) << '\n';
distance
函数(在<iterator>
中声明)接受一个迭代器范围,并返回该范围内的元素数量。返回类型是整数类型,尽管确切的类型(例如,int
或long int
)取决于实现。
编写测试程序来测试值 3、4、8、0 和 10。然后使用以下样本输入运行程序:
9 4 2 1 5 4 3 6 2 7 4
程序应该打印出什么样的排序向量?
在表 45-1 中填入每个值的下限和上限的期望值。然后运行程序检查你的答案。
表 45-1。
测试二分搜索法函数的结果
|价值
|
预期下限
|
预期上限
|
实际下限
|
实际上限
|
| --- | --- | --- | --- | --- |
| 3
| | | | |
| 4
| | | | |
| 8
| | | | |
| 0
| | | | |
| 10
| | | | |
在清单 45-8 中将你的测试程序与我的进行比较。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
import data;
int main()
{
intvector data{};
read_data(data);
std::ranges::sort(data);
write_data(data);
for (int test : { 3, 4, 8, 0, 10 })
{
auto lb{std::lower_bound(data.begin(), data.end(), test)};
auto ub{std::upper_bound(data.begin(), data.end(), test)};
std::cout << "bounds of " << test << ": { "
<< std::distance(data.begin(), lb) << ", "
<< std::distance(data.begin(), ub) << " }\n";
}
}
Listing 45-8.Exploring the lower_bound and upper_bound Functions
编写这个程序的一个更好的方法是调用equal_range
,它在一次数据传递中找到下限和上限。它返回一个迭代器的pair
:pair
的first
成员是下界,second
是上界。
比较
要检查两个范围是否相等,即它们包含相同的值,请调用equal
算法。该算法对一个范围和第二个范围的开始采用一个开始和一个结束迭代器。您必须确保这两个范围具有相同的大小。如果两个范围的每个元素都相等,则返回 true。如果有任何元素不匹配,它将返回 false。该函数有两种形式:只将迭代器传递给equal
,它用==
操作符比较元素;传递一个比较仿函数作为最后一个参数,equal
通过调用仿函数比较元素。函子的第一个参数是来自第一个范围的元素,第二个参数是来自第二个范围的元素。
mismatch
功能则相反。它比较两个范围并返回一个包含两个迭代器的对象,这两个迭代器引用第一个不匹配的元素。pair
中的in1
成员是引用第一个范围内元素的迭代器,in2
成员是引用第二个范围的迭代器。如果两个范围相等,返回值是一对末端迭代器。
用于设置最长算法名称记录的lexicographical_compare
算法。它比较两个范围,并确定第一个范围是否“小于”第二个范围。这是通过一次比较一个元素的范围来实现的。如果范围相等,函数返回 false。如果两个范围在一个范围结束时相等,而另一个范围较长,则较短的范围小于较长的范围。如果发现元素不匹配,则包含较小元素的范围就是较小的范围。使用<
操作符(或调用者提供的谓词)比较所有元素,并检查它们是否等价,而不是相等。回想一下,如果下列条件成立,元素a
和b
是等价的:
not (a < b) and not (b < a)
如果您将lexicographical_compare
应用于两个字符串,您将得到预期的小于关系,这解释了名称。换句话说,如果你用字符串"hello"
和"help"
调用这个算法,它返回 true 如果用"help"
和"hello"
调用,返回 false 而如果用"hel"
和"hello"
调用,则返回 true。如果你很好奇,它把最长名字的王冠输给了它的表亲lexicographical_compare_three_way
,这是一个相似的名字,但可以同时比较相等和大于。
写一个测试程序,将两个整数序列读入不同的向量。您可以通过在两组数据之间放置一个非数字字符串来做到这一点。当第一组数字到达非数字字符串时,读取失败。调用std::cin.clear()
清除失败状态,读取并丢弃分隔符字符串,然后读取第二组数据。然后在两个量程上测试equal
、mismatch
和lexicographical_compare
功能。
表 45-2 列出了一些建议的输入数据集。
表 45-2。
测试比较算法的建议数据集
|数据集 1
|
数据集 2
|
| --- | --- |
| 1 2 3 4 5
| 1 2 3
|
| 1 2 3
| 1 2 3 4 5
|
| 1 2 3 4 5
| 1 2 4 5
|
| 1 2 3
| 1 2 3
|
在清单 45-9 中将你的测试程序与我的进行比较。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
import data;
int main()
{
intvector data1{};
intvector data2{};
read_data(data1);
std::cin.clear();
std::string discard;
std::cin >> discard;
read_data(data2);
std::cout << "data1: ";
write_data(data1);
std::cout << "data2: ";
write_data(data2);
std::cout << std::boolalpha;
std::cout << "equal(data1, data2) = " << std::ranges::equal(data1, data2) << '\n';
auto result{std::ranges::mismatch(data1, data2)};
std::cout << "mismatch(data1, data2) = index " <<
std::distance(data1.begin(), result.in2) << '\n';
std::cout << "lex_comp(data1, data2) = " <<
std::ranges::lexicographical_compare(data1, data2) << '\n';
}
Listing 45-9Testing Various Comparison Algorithms
重新排列数据
你已经看过很多次sort
算法了。其他算法也擅长重新排列一个范围内的值。merge
算法将两个排序的输入范围合并成一个输出范围。与往常一样,您必须确保输出范围有足够的空间来接受来自两个输入范围的整个合并结果。这两个输入范围可以是不同的大小,所以merge
有五或六个参数:两个用于第一个输入范围,两个用于第二个输入范围,一个用于输出范围的开始,还有一个可选参数供仿函数使用,以代替<
运算符。
replace
算法扫描输入范围,并用新值替换每次出现的旧值。替换就地发生,所以您指定了通常的范围,但没有写迭代器。replace_if
函数与此类似,但它采用一个谓词而不是一个旧值。写一个程序,读取一个整数向量并用 0 替换所有出现在[10,20]范围内的值。重用intrange
仿函数类或者写一个 lambda。将您的程序与清单 45-10 中的程序进行比较。
import <algorithm>;
import data;
import intrange;
int main()
{
intvector data{};
read_data(data);
write_data(data);
std::ranges::replace_if(data, intrange{10, 20}, 0);
write_data(data);
}
Listing 45-10.Using replace_if and intrange to Replace All Integers in [10, 20] with 0
清单 45-11 显示了使用 lambda 的相同程序。
import <algorithm>;
import <ranges>;
import data;
int main()
{
intvector data{};
read_data(data);
write_data(data);
std::ranges::replace_if(data, [](int x) { return x >= 10 and x <= 20; }, 0);
write_data(data);
}
Listing 45-11.Using replace_if and a Lambda to Replace All Integers in [10, 20] with 0
一个有趣的算法是shuffle
,它将元素随机排列。这个函数有两个参数,指定混洗的范围和一个伪随机数生成器。对于第二个参数,使用与清单 45-5 中相同的std::default_random_engine
。
使用sequence
模块(来自清单 44-6 )并生成一个 100 个连续整数的向量。然后随机排序并打印出来。在清单 45-12 中将你的解决方案与我的进行比较。
import <algorithm>;
import <random>;
import data;
import sequence;
int main()
{
intvector data(100);
std::ranges::generate(data, sequence{1, 1});
write_data(data);
std::ranges::shuffle(data, std::default_random_engine{});
write_data(data);
}
Listing 45-12.Shuffling Integers into Random Order
generate
算法重复调用不带参数的仿函数,并将返回值复制到输出范围。它对范围内的每个元素调用一次仿函数,覆盖每个元素。generate_n
函数接受一个迭代器作为范围的开始,接受一个整数作为范围的大小。然后,它对该范围的每个元素调用一次仿函数(第三个参数),将返回值复制到该范围中。您有责任确保产品系列中确实有这么多元素。要在清单 45-12 中使用generate_n
而不是generate
,您可以这样写
std::generate_n(data.begin(), data.size(), sequence{1, 1});
如果您不必为一个范围内的每一项调用仿函数,而是希望用相同值的副本填充一个范围,那么调用fill
,传递一个范围和值。该值被复制到范围内的每个元素中。fill_n
函数采用一个起始迭代器和一个整数大小来指定目标范围。
transform
算法通过为输入范围内的每个项目调用一个仿函数来修改项目。它将变换后的结果写入输出范围,该范围可以与输入范围相同,从而就地修改范围。你已经看到这个算法在工作,所以我不会增加你已经知道的东西。该函数有两种形式:一元和二元。一元形式接受一个输入范围、一个输出范围的开始和一个函子。它为输入范围的每个元素调用仿函数,将结果复制到输出范围。输出范围可以与输入范围相同,也可以是单独的范围。与所有算法一样,您必须确保输出范围足够大以存储结果。
transform
的二进制形式接受两个输入范围、一个输出范围的开始和一个二进制函子。对输入范围中的每个元素调用仿函数;第一个参数来自第一个输入范围,第二个参数来自第二个输入范围。与一元形式一样,该函数将结果复制到输出范围,该范围可以与任一输入范围相同。注意,两个输入范围的类型不必相同。
复制数据
一些算法就地运行,其他算法将其结果复制到输出范围。例如,reverse
就地反转项目,reverse_copy
保持输入范围不变,并将反转的项目复制到输出范围。如果一个算法的拷贝形式存在,它的名字后面会加上_copy
。(除非它也是一个函数的谓词形式,在这种情况下,它在_copy
后附加了_if
,如replace_copy_if
。)
除了您已经见过很多次的普通的copy
之外,标准库还提供了copy_backward
,它制作一个副本,但从结尾开始,向开头移动,保持原始顺序;copy_n
,它接受一个范围的开始、一个计数和一个写迭代器;和copy_if
,它类似于copy
,但是接受一个谓词,并且仅当谓词返回 true 时才复制一个元素。区分copy_backward
和reverse_copy
。后者从输入范围的开头开始,一直到末尾,但是以相反的顺序复制值。
如果你必须移动元素而不是复制它们,调用std::move
或std::move_backward
。这个std::move
和你在探索中遇到的 40 不一样。这个是在<algorithm>
中声明的。像copy
一样,move
算法接受一个输入范围和一个写迭代器。它为输入范围的每个元素调用另一种形式的std::move
,将元素移入输出范围。
与所有写入输出的算法一样,您有责任确保输出范围足够大,能够处理您写入其中的所有内容。标准库的一些实现提供了调试模式来帮助检测违反该规则的情况。如果你的库提供了这样的功能,无论如何,要充分利用它。
删除元素
最难使用的算法是那些“移除”元素的算法。正如你在探索 23 中学到的,像remove
这样的算法实际上不会删除任何东西。相反,它们会重新排列该范围内的元素,以便将所有预定要删除的元素打包到该范围的末尾。删除算法返回一个子范围对象,其中包含该范围的剩余元素。
remove
函数接受一个迭代器范围和一个值,并删除所有等于该值的元素。您还可以使用带有remove_if
的谓词来删除谓词返回 true 的所有元素。这两个函数也有复制的对应物,它们不重新排列任何东西,只是复制没有被删除的元素:remove_copy
复制不等于某个值的所有元素,remove_copy_if
复制谓词返回 false 的所有元素。
另一种去除元素的算法是unique
(和unique_copy
)。它接受一个输入范围并删除所有相邻的重复项,从而确保该范围内的每一项都是唯一的。(如果范围已排序,则所有重复项都是相邻的。)这两个函数都可以使用比较函子,而不是使用默认的==
操作符。
当remove()
返回一个子范围时,返回值实际上包含两个迭代器,这两个迭代器限定了要删除的元素的范围。复制的唯一数据是通过重新组织向量从原始范围中“删除”的值。所有的算法都以同样的方式对待任何范围,不管这个范围是一个向量、一对迭代器端点,还是任何可以用两个端点表示的东西。
写一个程序,将整数读入一个向量,将值按降序排序,擦除所有偶数元素,并在删除重复项的同时复制到一个向量。打印产生的矢量。用模数(%
)运算符测试偶数:x % 2 == 0
时x
为偶数。我的解决方案在清单 45-13 中。
import <algorithm>;
import <iterator>;
import <ranges>;
import data;
import intrange;
int main()
{
intvector data{};
read_data(data);
// sort into descending order
std::ranges::sort(data, [](int a, int b) { return b < a; });
auto odd{ std::ranges::remove_if(data, [](int x) { return x % 2 == 0; }) };
intvector uniquely_odd{};
std::unique_copy(begin(data), begin(odd), std::back_inserter(uniquely_odd));
write_data(uniquely_odd);
}
Listing 45-13.Erasing Elements from a Vector
迭代程序
算法、范围和迭代器密切相关。当描述如何使用各种迭代器、范围和子范围的算法时,我挥了挥手。在深入研究范围之前,我们需要进一步了解迭代器以及如何有效地使用它们,这是下一篇文章的主题。
四十六、关于迭代器的更多信息
迭代器提供了对一系列事物的逐个元素的访问。这些东西可以是数字、字符或几乎任何类型的对象。标准容器,比如vector
,提供了对容器内容的迭代器访问,其他标准迭代器允许您访问输入流和输出流。考虑范围的一种方式是一对迭代器。标准算法需要迭代器来对事物序列进行操作。
到目前为止,您对迭代器的看法和使用还有些局限。当然,你用过它们,但是你真的了解它们吗?这种探索有助于理解迭代器到底发生了什么。
迭代器的种类
到目前为止,您已经看到迭代器有多种形式,特别是读和写。您看到了可以从一对迭代器中构造一个vector
,比如std::istream_iterator
。std::ranges::copy
函数需要一个写迭代器作为复制目的地。
然而,一直以来,我都通过引用“读”和“写”迭代器来简化情况。事实上,C++ 有六种不同类别的迭代器:输入、输出、正向、双向、随机访问和连续。每个类别都有额外的特征来进一步描述特定迭代器能做什么或不能做什么。输入和输出迭代器的功能最少,contiguous 的功能最多。您可以在任何需要迭代器功能较少的地方用功能较多的迭代器来代替。图 46-1 说明了迭代器的可替换性。然而,不要被这个数字误导了。它不显示类继承。使一个对象成为迭代器的是它的行为。例如,如果它满足了一个输入迭代器的所有要求,它就是一个输入迭代器,不管它是什么类型。
图 46-1。
迭代器的替换树
所有迭代器都可以自由复制和赋值。复制或赋值的结果是一个新的迭代器,它引用与原始迭代器相同的项。其他特征取决于迭代器类别,如下面几节所述。
输入迭代器
不出所料,输入迭代器只支持输入。每次迭代只能从迭代器读取一次(使用一元*
操作符)。您不能修改迭代器引用的项。++
操作者前进到下一个输入项目。你可以比较迭代器的相等和不相等,但是唯一有意义的比较是比较一个迭代器和一个末端迭代器。一般来说,您不能比较两个输入迭代器来查看它们是否引用同一个项。
大概就是这样。输入迭代器非常有限,但也非常有用。许多标准算法用输入迭代器来表示它们的输入。istream_iterator
类型是输入迭代器的一个例子。还可以将任何容器的迭代器视为输入迭代器,例如,vector 的begin()
成员函数返回的迭代器。
输出迭代器
输出迭代器只支持输出。您可以通过将*
操作符应用到赋值左边的迭代器来给迭代器项赋值,但是您不能从迭代器中读取。每次迭代只能修改一次迭代器值。++
运算符前进到下一个输出项目。
您不能比较输出迭代器是否相等。
尽管输出迭代器有局限性,但它们也被标准算法广泛使用。每个将数据复制到输出范围的算法都需要一个输出迭代器来指定范围的开始。
处理输出迭代器时需要注意的一点是,必须确保迭代器实际写入的地方有足够的空间来存储整个输出。任何错误都会导致未定义的行为。一些实现提供了可以检查这种错误的调试迭代器,当这些工具可用时,您当然应该利用它们。然而,不要仅仅依赖于调试库。在使用输出(和其他)迭代器时,仔细的代码设计、仔细的代码实现和仔细的代码审查对于确保安全是绝对必要的。
ostream_iterator
类型是输出迭代器的一个例子。您也可以将许多容器的迭代器视为输出迭代器,例如,vector 的begin()
成员函数返回的迭代器。
正向迭代器
前向迭代器具有输入迭代器和输出迭代器的所有功能,还有更多的功能。您可以自由地读取和写入一个迭代器项(仍然使用一元操作符*
),并且您可以根据需要经常这样做。++
操作符前进到下一项,==
和!=
操作符可以比较迭代器,看它们是指同一项还是指结束位置。
一些算法需要前向迭代器,而不是输入迭代器。在之前的探索中,我忽略了这个细节,因为它很少影响到你。例如,二分搜索法算法需要前向迭代器来指定输入范围,因为它们可能需要不止一次地引用一个特定的项。这意味着你不能直接使用一个istream_iterator
作为一个参数,比如说,lower_bound
,但是你不太可能在一个真实的程序中尝试这样做。所有容器的迭代器都满足前向迭代器的要求,所以实际上,这个限制影响很小。
双向迭代器
双向迭代器具有正向迭代器的所有功能,但是它还支持--
操作符,该操作符将迭代器向后移动一个位置,到达前一项。与任何迭代器一样,您有责任确保迭代器不会超出范围的结尾或开头。
reverse
和reverse_copy
算法(以及其他一些算法)需要双向迭代器。大多数容器的迭代器至少满足双向迭代器的要求,所以您很少需要担心这个限制。
随机存取迭代器
一个随机访问迭代器拥有所有其他迭代器的所有功能,另外你可以通过增加或减少一个整数来移动迭代器任意的数量。
你可以减去两个迭代器(假设它们引用相同的对象序列)来获得它们之间的距离。回想一下 Exploration 45 中的distance
函数返回两个迭代器之间的距离。如果向函数传递正向或双向迭代器,它会一次向前推进起始迭代器一步,直到到达结束迭代器。只有这样它才会知道距离。如果你传递随机访问迭代器,它只是减去两个迭代器,并立即返回它们之间的距离。
可以比较随机访问迭代器是否相等。如果两个迭代器引用相同的对象序列,也可以使用任何关系运算符。对于随机访问迭代器,a < b
意味着a
引用序列中比b
更早的一个项目。
像sort
这样的算法需要随机访问迭代器。vector
类型提供了随机访问迭代器,但并不是所有的容器都提供。例如,list
容器实现了一个双向链表,所以它只有双向迭代器。因为不能使用sort
算法,list
容器有自己的sort
成员函数。在探索 56 中了解更多关于list
的信息。
连续迭代器
连续迭代器具有所有其他迭代器的所有功能,而且它适用于存储在相邻内存位置的元素。向量或数组有连续的迭代器,但其他容器没有。
现在你知道了向量提供了连续的迭代器,并且你可以使用关系操作符来比较连续的(和随机访问的)迭代器,重新看看清单 10-4。你能想出一个更简单的方法来写这个程序吗?(提示:考虑一个循环条件start < end
。)参见我在清单 46-1 中的重写。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
int main()
{
std::vector<int> data{
std::istream_iterator<int>(std::cin),
std::istream_iterator<int>()
};
for (auto start{data.begin()}, end{data.end()}; start < end; ++start)
{
--end; // now end points to a real position, possibly start
std::iter_swap(start, end); // swap contents of two iterators
}
std::copy(data.begin(), data.end(),
std::ostream_iterator<int>(std::cout, "\n"));
}
Listing 46-1.Comparing Iterators by Using the < Operator
这次我使用了std::copy()
,它使用了一对迭代器而不是一个范围,只是为了展示迭代器对是如何进行输入和输出的。
因此,输入、正向、双向、随机访问和连续迭代器都可以称为“读”迭代器,输出、正向、双向、随机访问和连续迭代器都可以称为“写”迭代器。一个算法,比如copy
,可能只需要输入和输出迭代器。也就是说,输入范围需要两个输入迭代器。您可以使用任何满足输入迭代器要求的迭代器:输入迭代器、正向迭代器、双向迭代器、随机访问迭代器或连续迭代器。对于输出范围的开始,使用任何满足输出迭代器要求的迭代器:输出、前向、双向、随机访问或连续。
使用迭代器
迭代器最常见的来源是所有容器(如map
和vector
)提供的begin()
和end()
成员函数。begin()
成员函数返回一个引用容器第一个元素的迭代器,end()
返回一个引用容器最后一个元素位置的迭代器。
begin()
空集装箱返回什么?****
**_____________________________________________________________
如果容器为空,begin()
返回与end()
相同的值,即表示“超过结尾”的特殊值,不能解引用。测试容器是否空的一种方法是测试begin() == end()
。(更好的是,尤其是当你正在编写一个真正的程序,而不是试图说明迭代器的本质时,调用每个容器都提供的empty()
成员函数。)
每个容器以不同的方式实现其迭代器。对你来说最重要的是迭代器满足一个标准类别的要求。
迭代器的确切类别取决于容器。返回连续的迭代器。一个map
返回双向迭代器。任何库参考都会告诉你每个容器支持哪种迭代器。
许多算法和容器成员函数也返回迭代器。例如,几乎每个执行搜索的函数都返回一个指向所需项的迭代器。如果函数找不到该项,它将返回结束迭代器。返回值的类型通常与输入范围中迭代器的类型相同。将元素复制到输出范围的算法返回结果迭代器。
一旦有了迭代器,就可以用*
去引用它,以获得它所引用的值(除了输出迭代器和结束迭代器,输出迭代器的去引用只是为了分配一个新值,而结束迭代器永远不能去引用)。如果迭代器引用一个对象,而你想访问该对象的一个成员,你可以使用简写的-
>
符号。
std::vector<std::string> lines(2, "hello");
std::string first{*lines.begin()}; // dereference the first item
std::size_t size{lines.begin()->size()}; // dereference and call a member function
您可以通过调用next
或advance
函数(在<iterator>
中声明)将迭代器推进到新的位置。advance
函数修改作为第一个参数传递的迭代器。next
函数接受迭代器的值,并返回一个新的迭代器值。第二个参数是推进迭代器的整数距离。第二个参数对于next
是可选的;默认为 1。如果迭代器是随机访问的,那么这个函数会把这个距离加到迭代器上。任何其他类型的迭代器都必须多次应用它的 increment ( ++
)操作符来前进所需的距离,例如:
std::vector<int> data{ 1, 2, 3, 4, 5 };
auto iter{ data.begin() };
std::cout << "4 == " << *std::next(iter, 3) << '\n';
对于一个 vector 来说,std::next()
就像加法一样,但是对于其他容器,比如std::map
,它多次应用 increment 运算符,以到达期望的目的地。如果你有一个反向迭代器,你可以传递一个负的距离或者调用std::prev()
。如果迭代器是双向的,第二个参数可以是负的,这样就可以返回。您可以推进输入迭代器,但不能推进输出迭代器。重用 Exploration 44 中的sequence
仿函数,读取清单 46-2 中的程序。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
import data; // see Listing 45-2.
import sequence; // see Listing 44-6.
int main()
{
intvector data(10);
// fill with even numbers
std::generate(data.begin(), data.end(), sequence{0, 2});
auto iter{data.begin()};
std::advance(iter, 4);
std::cout << *iter << ", ";
iter = std::prev(iter, 2);
std::cout << *iter << '\n';
}
Listing 46-2.Advancing an Iterator
这个程序打印什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
data
向量用偶数填充,从 0 开始。迭代器iter
最初指的是向量的第一个元素,即 0。迭代器前进四个位置,值为 8,然后后退两个位置,值为 4。所以输出是
8, 4
声明变量来存储迭代器是笨拙的。类型名又长又麻烦。所以我经常用auto
来定义一个变量。有时你需要明确地命名一个迭代器类型。这通常是通过成员类型名来完成的。清单 46-3 展示了迭代器成员类型的一些用法。
import <iostream>;
import <string>;
import <vector>;
int main()
{
std::vector<std::string> lines{2, "hello"};
std::vector<std::string>::iterator iter{lines.begin()};
*iter = "good-bye"; // dereference and modify the first item
std::size_t size{iter->size()}; // dereference and call a member function
std::vector<std::string>::const_iterator citer{lines.cbegin()};
std::cout << *citer << '\n';
std::cout << size << '\n';
}
Listing 46-3.Demonstrating Iterator Member Types
成员类型const_iterator
产生容器的const
元素。iterator
类型产生可修改的成员。下一节将更仔细地研究const_iterator
。
const_iterator 与 const iterator
混淆的一个小来源是const_iterator
和const iterator
之间的区别。输出迭代器(以及任何满足输出迭代器要求的迭代器,即正向迭代器、双向迭代器、随机访问迭代器和连续迭代器)允许您修改它引用的项。对于一些前向迭代器(双向的、随机访问的和连续的),您希望将该范围内的数据视为只读。即使迭代器本身满足前向迭代器的要求,您的直接需求可能只是输入迭代器。
您可能认为声明迭代器const
会有所帮助。毕竟,这就是你要求编译器帮助你的方式,通过防止意外修改变量:用const
说明符声明变量。你怎么想呢?行得通吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
如果你不确定,试一试。阅读清单 46-4 和预测其产量。使用与 Exploration 45 相同的data
模块。
import <iostream>;
import <iterator>;
import data;
int main()
{
intvector data{};
read_data(data);
const intvector::iterator iter{data.begin()};
std::advance(iter, data.size() / 2); // move to middle of vector
if (not data.empty())
std::cout << "middle item = " << *iter << '\n';
}
Listing 46-4.Printing the Middle Item of a Series of Integers
你能看出为什么编译器拒绝编译程序吗?可能你看不到确切的原因,埋在编译器的错误输出里。(下一节将更详细地讨论这个问题。)错误在于变量iter
是const
。你不能修改迭代器,所以你不能把它推进到向量的中间。
你不必将迭代器本身声明为const
,你必须告诉编译器你希望迭代器引用const
数据。如果向量本身是const
,那么begin()
函数将返回这样一个迭代器。您可以自由地修改迭代器的位置,但是不能修改迭代器引用的值。这个函数返回的迭代器的名字是const_iterator
(带下划线)。
换句话说,每个容器实际上都有两个不同的begin()
函数。一个是const
成员函数,返回const_iterator
。另一个不是const
成员函数;它返回一个普通的iterator
。与任何const
或非const
成员函数一样,编译器根据容器本身是否为const
来选择其中之一。如果容器不是const
,则得到begin()
的非const
版本,返回一个普通的iterator
,可以通过迭代器修改容器内容。如果容器是const
,您将得到begin()
的const
版本,它返回一个const_iterator
,这将阻止您修改容器的内容。您还可以通过调用cbegin()
来强制发布,它总是返回const_iterator
,即使对于非const
对象也是如此。
重写清单 46-4 以使用一个const_iterator
。你的程序应该看起来类似于清单 46-5 。
import <iostream>;
import <iterator>;
import data;
int main()
{
intvector data{};
read_data(data);
intvector::const_iterator iter{data.begin()};
std::advance(iter, data.size() / 2); // move to middle of vector
if (not data.empty())
std::cout << "middle item = " << *iter << '\n';
}
Listing 46-5.Really Printing the Middle Item of a Series of Integers
向自己证明,有了const_iterator
就不能修改数据。对你的程序做进一步修改,取中间值。现在你的程序应该如清单 46-6 所示。
import <iostream>;
import <iterator>;
import data;
int main()
{
intvector data{};
read_data(data);
intvector::const_iterator iter{data.begin()};
std::advance(iter, data.size() / 2); // move to middle of vector
if (not data.empty())
*iter = -*iter;
write_data(data);
}
Listing 46-6.Negating the Middle Value in a Series of Integers
如果你把const_iterator
改成iterator
,程序就工作了。做吧。
错误消息
当你编译清单 46-4 时,编译器发出一条错误消息,或者 C++ 标准编写者称之为诊断。例如,我每天使用的编译器 g++ 会打印以下内容:
In file included from /usr/include/c++/10/bits/stl_algobase.h:66,
from /usr/include/c++/10/bits/char_traits.h:39,
from /usr/include/c++/10/ios:40,
from /usr/include/c++/10/ostream:38,
from /usr/include/c++/10/iostream:39,
from list4604.cc:3:
/usr/include/c++/10/bits/stl_iterator_base_funcs.h: In instantiation of 'constexpr void std::__advance(_RandomAccessIterator&, _Distance, std::random_access_iterator_tag) [with _RandomAccessIterator = const __gnu_cxx::__normal_iterator<int*, std::vector<int> >; _Distance = long int]':
/usr/include/c++/10/bits/stl_iterator_base_funcs.h:206:21: required from 'constexpr void std::advance(_InputIterator&, _Distance) [with _InputIterator = const __gnu_cxx::__normal_iterator<int*, std::vector<int> >; _Distance = long unsigned int]'
list4604.cc:11:37: required from here
/usr/include/c++/10/bits/stl_iterator_base_funcs.h:181:2: error: passing 'const __gnu_cxx::__normal_iterator<int*, std::vector<int> >' as 'this' argument discards qualifiers [-fpermissive]
181 | ++__i;
| ^~~~~
In file included from /usr/include/c++/10/bits/stl_algobase.h:67,
from /usr/include/c++/10/bits/char_traits.h:39,
from /usr/include/c++/10/ios:40,
from /usr/include/c++/10/ostream:38,
from /usr/include/c++/10/iostream:39,
from list4604.cc:3:
/usr/include/c++/10/bits/stl_iterator.h:975:7: note: in call to 'constexpr __gnu_cxx::__normal_iterator<_Iterator, _Container>& __gnu_cxx::__normal_iterator<_Iterator, _Container>::operator++() [with _Iterator = int*; _Container = std::vector<int>]'
975 | operator++() _GLIBCXX_NOEXCEPT
| ^~~~~~~~
In file included from /usr/include/c++/10/bits/stl_algobase.h:66,
from /usr/include/c++/10/bits/char_traits.h:39,
from /usr/include/c++/10/ios:40,
from /usr/include/c++/10/ostream:38,
from /usr/include/c++/10/iostream:39,
from list4604.cc:3:
/usr/include/c++/10/bits/stl_iterator_base_funcs.h:183:2: error: passing 'const __gnu_cxx::__normal_iterator<int*, std::vector<int> >' as 'this' argument discards qualifiers [-fpermissive]
那么这些官样文章是什么意思呢?虽然一个 C++ 高手也能搞清楚,但对你的帮助可能不大。隐藏在中间的是行号和源文件,它们标识了错误的来源。这就是你要开始寻找的地方。编译器直到开始处理各种模块时才发现错误。文件名取决于标准库的实现,所以您不能总是从这些文件名中判断出实际的错误是什么。
在这种情况下,当std::advance
函数试图将++
操作符应用到迭代器时,就会出现错误。这时编译器检测到它有一个const
迭代器,但是它没有任何与const
迭代器一起工作的函数。关于“丢弃限定词”的消息意味着编译器能够继续的唯一方法是去掉iter
对象上的const
限定词。
不要放弃理解 C++ 编译器错误信息的希望。到本书结束时,你将获得更多的知识,这将帮助你理解编译器和库是如何工作的,这种理解将帮助你理解这些错误信息。
我的建议是,处理大量令人困惑的错误信息时,首先要找到你的源文件。这应该会告诉您引起问题的行号。检查源文件。你可能会发现一个明显的错误。如果没有,请检查错误消息文本。忽略“从此处实例化”和类似的消息。尝试找到真正的错误消息,它通常以error:
开始,而不是以warning:
或note:
开始。
特化迭代器
<iterator>
头定义了许多有用的、专门的迭代器,比如back_inserter
,你已经见过几次了。严格来说,back_inserter
是一个返回迭代器的函数,但是你很少需要知道确切的迭代器类型。
除了back_inserter
,还可以使用front_inserter
,它也是以容器为参数,返回一个输出迭代器。每次给解引用迭代器赋值时,它都会调用容器的push_front
成员函数,将值插入容器的开头。
inserter
函数接受一个容器和一个迭代器作为参数。它返回一个调用容器的insert
函数的输出迭代器。insert
成员函数需要一个iterator
参数,指定插入值的位置。inserter
迭代器最初传递第二个参数作为插入位置。在每次插入之后,它更新它的内部迭代器,所以后续的插入会进入后续的位置。换句话说,inserter
只是做正确的事情。
其他专门的迭代器包括istream_iterator
和ostream_iterator
,您也已经看到了。一个istream_iterator
是一个输入迭代器,当你解引用迭代器时,它从流中提取值。在没有参数的情况下,istream_iterator
构造器创建一个流尾迭代器。当输入操作失败时,迭代器相当于流尾迭代器。
一个ostream_iterator
是一个输出迭代器。构造器将输出流和可选字符串作为参数。赋值给被解引用的迭代器会将一个值写入输出流,可选地后跟字符串(来自构造器)。
另一个专门的迭代器是reverse_iterator
类。它采用了一个现有的迭代器(称为基础迭代器),这个迭代器必须是双向的(或者是随机访问的或者是连续的)。当反向迭代器前进时(++
),基迭代器后退(--
)。支持双向迭代器的容器有rbegin()
和rend()
成员函数,它们返回反向迭代器。rbegin()
函数返回一个反向迭代器,指向容器的最后一个元素,rend()
返回一个特殊的反向迭代器值,表示容器开头之前的一个位置。因此,您将范围[ rbegin()
,rend()
]视为普通的迭代器范围,以逆序表示容器的值。
C++ 不允许迭代器指向开头之前的一个位置,所以反向迭代器的实现有点古怪。通常,实现细节并不重要,但是reverse_iterator
在其返回基本迭代器的base()
成员函数中公开了这个特殊的细节。
我可以告诉你基本迭代器实际上是什么,但那会让你失去乐趣。写一个程序来揭示 reverse_iterator
的基本迭代器的本质。(提示:用整数序列填充一个向量。使用反向迭代器获得中间值。与迭代器的base()
迭代器的值进行比较。)
如果一个 reverse_iterator
指向一个容器的位置 x,那么它的 base()
迭代器指向什么?
如果您没有回答 x + 1,请尝试运行清单 46-7 中的程序。
import <algorithm>;
import <iostream>;
import data;
import sequence;
int main()
{
intvector data(10);
std::generate(data.begin(), data.end(), sequence(1));
write_data(data); // prints { 1 2 3 4 5 6 7 8 9 10 }
intvector::iterator iter{data.begin()};
iter = iter + 4; // iter is contiguous
std::cout << *iter << '\n'; // prints 5
intvector::reverse_iterator rev{data.rbegin()};
std::cout << *rev << '\n'; // prints 10
rev = rev + 4; // rev is also contiguous
std::cout << *rev << '\n'; // prints 6
std::cout << *rev.base() << '\n'; // prints 7
std::cout << *data.rend().base() << '\n'; // prints 1
}
Listing 46-7.Revealing the Implementation of reverse_iterator
现在你明白了吗?基本迭代器总是指向反向迭代器位置之后的一个位置。这就是允许rend()
指向“开始之前”的位置的诀窍,尽管这是不允许的。在幕后,rend()
迭代器实际上有一个指向容器中第一项的基迭代器,reverse_iterator
对*
操作符的实现执行了获取基迭代器,后退一个位置,然后解引用基迭代器的魔术。
如您所见,迭代器比最初看起来要复杂一些。然而,一旦你理解了它们是如何工作的,你会发现它们实际上非常简单,功能强大,并且易于使用。迭代器是范围库的基础。您可以将一个范围看作一对迭代器,但它们比这稍微复杂一些,下一篇文章将对此进行解释。**
四十七、范围、视图和适配器
在本书的前面,我已经谈到了“范围”,只是模糊地描述了一个范围到底是什么。一部分是因为范围是用 C++ 相当高级的特性定义的,我还没有介绍过,另一部分是因为 C++ 使用范围的方式,你不需要了解更多。这种探索开始揭开范围的神秘,并给你一些更有趣的冒险,包括一些非常强大的使用范围视图和范围适配器的编码技术。
范围
到目前为止,您已经遇到了两种类型的范围:向量和迭代器对。向量是一个范围,因为它存储了一系列值,而范围是访问这些值的一种方式。一对迭代器也可以通过解引用和递增起始迭代器直到它等于结束迭代器来表示一个范围。
更准确地说,范围由起始迭代器和结束标记来表征。一个 sentinel 可以是一个结束迭代器,但它不是必须的。sentinel 可能是代表范围终点的其他类型。链表的一个常见实现是使用一个 sentinel 节点来标记链表的末尾,因为这样比试图定义一个结束迭代器更容易编写代码。除非你想实现一个 range 类型,否则你不需要关心哨兵,除非你知道他们的存在。
所以一个范围有一个开始迭代器和结束标记。std::ranges::begin()
函数返回范围的开始,std::ranges::end()
返回标记。一些代码示例使用了data.begin()
和data.end()
,它们适用于向量,但不是每个范围。std::ranges
功能适用于所有范围类型。正如cbegin()
成员函数返回一个 const_iterator 一样,std::ranges::cbegin()
函数对任何范围都做同样的事情。
如果一个范围有一个已知的大小,比如一个vector
,std::ranges::size()
函数返回该大小。该函数仅针对可以快速返回其大小的范围类型定义。其他的,比如istream_view
,根本就没有对应的std::ranges::size()
功能。类似地,如果范围为空,std::ranges::empty()
返回 true,但只有在不修改范围的情况下可以确定范围时才被定义。所以不能用它来测试一个std::ranges::istream_view
的实例是否为空,但是可以用它来测试一个vector
。
清单 47-1 展示了其中一些范围函数。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
int main()
{
std::vector<int> data;
std::cout << "Enter some numbers:\n";
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(data));
std::cout << "You entered " << std::ranges::size(data) << " values\n";
if (not std::ranges::empty(data))
{
std::ranges::sort(data);
auto start{ std::ranges::cbegin(data) };
auto middle{ start + std::ranges::size(data) / 2 };
std::cout << "The median value is " << *middle << '\n';
}
}
Listing 47-1.Demonstrating Range Functions
给定一个迭代器和一个兼容的标记,您可以通过将迭代器和标记传递给构造器来构造一个std::ranges::subrange
对象,从而定义一个范围。子区域不从源区域复制任何元素。它只是抓住迭代器和哨兵。正如std::advance
推进一个迭代器,std::next()
返回一个新迭代器一样,subrange 类实现了advance()
成员函数来推进起始迭代器。成员函数next()
返回一个新的子范围,其起始位置前进了一位。这两个函数都需要一个参数来提升多个位置。例如,下面将数字 3、4 和 5 打印到标准输出中:
std::vector<int> data{ 1, 2, 3, 4, 5 };
std::ranges::subrange sub{ std::ranges::begin(data), std::ranges::end(data) };
std::ranges::copy(sub.next(2), std::ostream_iterator<int>(std::cout, "\n"));
正如迭代器有不同的风格一样,范围也是如此。输入范围是以输入迭代器作为起始迭代器的范围。输出范围有一个起始输出迭代器。其他迭代器类型也有相应的范围类型:forward_range、bidirectional_range、random_access_range 和 contiguous_range。范围的行为和限制与它们各自的迭代器相同。
范围有额外的特征,比如common_range
,这是 sentinel 类型与起始迭代器类型相同的时候。标准库中的所有容器类型(如vector
)都是公共范围。一个viewable_range
是一个可以用作视图的范围,这是下一节的主题。
范围视图
视图是一种特殊的范围,其特点是轻量级复制。复制一个视图只是使另一个视图看起来和原始视图一样。销毁视图(例如当视图超出范围时)是即时的,因为查看范围内没有元素被销毁。子范围是一种视图,其他类型的视图包括single_view
,它获取单个对象并使其看起来像一个大小为 1 的范围。
std::ranges::iota_view
型类似于清单 44-5 的sequence
级。它接受一个或两个参数,并生成一个整数范围,带有一个起始值和一个可选的标记值。如果没有 sentinel 值,该范围将永远持续下去,直到整数值绕回并重复。
视图类型的一个有趣的方面是,您可以用两种不同的方式来构造一个视图。调用std::ranges::view::iota(start)
函数相当于构造std::ranges::iota_view{start}
。传递两个参数也是如此。
用std::ranges::single_view
做实验。写一个程序,从用户那里读取单个整数,构造一个 single_view,使用 ranged for 循环打印视图的每个元素。在清单 47-2 中将你的程序与我的程序进行比较。
import <iostream>;
import <ranges>;
int main()
{
std::cout << "Enter an integer: ";
int input{};
if (std::cin >> input)
{
for (auto x : std::ranges::single_view{input}) {
std::cout << x << '\n';
}
}
}
Listing 47-2.Demonstrating Range Functions
范围管道
视野很可爱,但是有什么用呢?标准库将竖线操作符(|
)定义为管道操作符,能够将数据从一个范围输送到视图管道中。例如,假设您正在为一个裁判事件编写评分软件。评分规则是舍弃高低分,计算剩余分数的平均值。你可以用迭代器做到这一点,但是调整起始迭代器和结束迭代器会很笨拙。或者你可以修改存储分数的向量,去掉第一个和最后一个。或者您可以使用视图,如清单 47-3 所示。
import <algorithm>;
import <iostream>;
import <ranges>;
import <vector>;
int main()
{
std::cout << "Enter the scores: ";
std::vector<int> scores{};
std::ranges::copy(std::ranges::istream_view<int>(std::cin),
std::back_inserter(scores));
std::ranges::sort(scores);
auto drop_high{ scores | std::ranges::views::take(scores.size() - 1) };
auto remaining_scores{ drop_high | std::ranges::views::drop(1) };
int sum{0};
int count{0};
for (int score : remaining_scores)
{
++count;
sum += score;
}
std::cout << "mean score is " << sum / count << '\n';
}
Listing 47-3.Computing Scores by Using Views
与其他视图一样,take
和drop
视图值得注意的是,分数向量永远不会被复制。取而代之的是,遍历一次向量,以很小的开销记录相关的分数。使这些管道如此有效的是对其数据执行处理的特殊视图。这些特殊视图被称为范围适配器,这是下一节的主题。
范围适配器
范围适配器是给定范围时创建视图的另一种方式。标准库包括几个有用的范围适配器,第三方可能会添加更多。
drop
视图
std::ranges::view::drop
适配器接受一个整数参数,跳过输入范围中的许多元素,传递所有后续元素,例如,跳过一个字符串的前两个字符(毕竟这是一个字符范围):
std::string string{"string"};
auto ring = string | std::ranges::views::drop(2);
drop_while
适配器与此类似,但是它使用谓词而不是计数。它跳过元素,直到谓词返回 true,并迭代该元素,然后是其后的所有剩余元素。
filter
视图
使用std::ranges::view::filter(predicate)
仅选择输入范围中通过谓词的元素。通常,这可以是一个函数、仿函数或 lambda,例如,只选择大于零的值:
auto positives = data
| std::ranges::views::filter([](auto value) { return value > 0; });
join
视图
用std::ranges::view::join
将一系列范围展平为单个范围。join 的常见用法是将一系列字符串连接成一系列字符,例如:
std::vector<std::string> words{ "this", " ", "is an", " ", "example" };
auto sentence = words | std::ranges::views::join;
keys
视图
对 map 进行迭代会为每个元素生成一对键和值。通常,您只需要迭代键,这可以用std::ranges::view::keys
来完成,例如:
std::map<std::string, int> barn{ {"horse", 3}, {"dog", 4}, {"cat", 0} };
for (auto const& animal : barn | std::ranges::views::keys)
std::cout << animal << '\n';
reverse
视图
如果范围是双向的,std::ranges::views::reverse
适配器以相反的顺序迭代它,例如:
std::map<std::string, int> barn{ {"horse", 3}, {"dog", 4}, {"cat", 0} };
auto animals{ barn | std::ranges::views::reverse | std::ranges::views::keys };
for (auto const& animal : animals)
std::cout << animal << '\n';
transform
视图
std::ranges::view::transform
适配器接受一个函数或 lambda 参数,并将范围内的每个元素传递给该函数,用该函数返回的值替换元素,例如,将字符串范围更改为整数字符串长度范围,然后只保留大于三的长度:
std::vector<std::string> strings{"string", "one", "two", "testing" };
auto sizes = strings
| std::ranges::views::transform([](auto&& str) { return str.size(); })
| std::ranges::views::filter([](auto size) { return size > 3; });
take
视图
std::ranges::view::take
适配器接受一个整数参数,并产生输入范围的多个元素,例如,从第一个元素开始,只保留字符串的前三个字符:
std::string string{"string"};
std::ranges::copy(string | std::ranges::views::take(3),
std::ostreambuf_iterator(std::cout));
take_while
适配器与此类似,但是它使用谓词而不是计数。take_while
适配器迭代元素,直到谓词返回 false,之后它停止迭代输入范围的剩余部分。
values
视图
对 map 进行迭代会为每个元素生成一对键和值。通常,您只需要迭代值,这可以用std::ranges::view::values
来完成,例如:
std::map<std::string, int> barn{ {"horse", 3}, {"dog", 4}, {"cat", 0} };
int total{0};
for (auto count : barn | std::ranges::views::values)
total += count;
这不是一个详尽的列表,尽管它涵盖了大多数视图适配器。这些示例展示了使用管道组装视图适配器的几种方式。如果你喜欢函数调用语法,C++ 提供了一点灵活性。以下管道都做同样的事情,创建一个[2, 7)
的视图:
auto data{ std::ranges::views::iota(0, 10) }; // [0, 10)
auto demo1 = data | std::ranges::views::drop(2) | std::ranges::views::take(5);
auto demo2 = std::ranges::views::drop(data, 2) | std::ranges::views::take(5);
auto demo3 = std::ranges::views::take(std::ranges::views::drop(data, 2), 5);
auto demo4 = std::ranges::views::take(5)(std::ranges::views::drop(2)(data));
许多程序都涉及到在一个范围内迭代。这可能是编程最基本的方面。语言提供了各种各样的结构来遍历一个范围,C++ 有三种不同的循环。最后,您可以使用 ranged for 循环、范围、视图和适配器做任何事情。
现在是时候学习一些更重要的 C++ 编程技术了。下一篇文章将介绍异常和异常处理,这是正确处理程序员和用户错误的必要主题。
四十八、异常
到目前为止,您可能已经对探索中缺乏错误检查和错误处理感到沮丧。这种情况即将改变。像大多数现代编程语言一样,C++ 支持异常作为跳出正常控制流程的一种方式,以响应错误或其他异常情况。这种探索引入了异常:如何抛出它们,如何捕捉它们,语言和库何时使用它们,以及您应该何时以及如何使用它们。
引入异常
Exploration 9 引入了vector
的at
成员函数,该函数检索特定索引处的向量元素。当时我写道,你所阅读的大多数程序都会用方括号来代替。现在是检查方括号和at
函数之间的区别的好时机。首先,看两个程序。清单 48-1 显示了一个使用向量的简单程序。
import <iostream>;
import <vector>;
int main()
{
std::vector<int> data{ 10, 20 };
data.at(5) = 0;
std::cout << data.at(5) << '\n';
}
Listing 48-1.Accessing an Element of a Vector
运行该程序时,您预计会发生什么?
试试看。实际上会发生什么?
向量索引 5 超出界限。data
的唯一有效索引是 0 和 1,所以程序以 nastygram 结束也就不足为奇了。现在考虑清单 48-2 中的程序。
import <iostream>;
import <vector>;
int main()
{
std::vector<int> data{ 10, 20 };
data[5] = 0;
std::cout << data[5] << '\n';
}
Listing 48-2.A Bad Way to Access an Element of a Vector
运行该程序时,您预计会发生什么?
试试看。实际上会发生什么?
向量索引 5 仍然超出界限。如果你仍然收到一个讨厌的程序,你会得到一个不同于以前的程序。另一方面,程序可能运行到完成,而没有指示任何错误。您可能会觉得这令人不安,但这就是未定义行为的情况。任何事情都有可能发生。
简而言之,这就是使用下标([]
)和at
成员函数的区别。如果索引无效,at
成员函数会导致程序以一种可预测、可控的方式终止。您可以编写额外的代码并避免终止,采取适当的措施在终止前进行清理,或者让程序结束。
另一方面,如果索引无效,下标操作符会导致未定义的行为。任何事情都可能发生,所以你无法控制——一点也不能。如果软件正在控制,比如说,一架飞机,那么“任何事情”都包含许多令人难以想象的选项。在一个典型的桌面工作站上,更可能的情况是程序崩溃,这是一件好事,因为它告诉你有什么地方出错了。最糟糕的可能后果是,没有明显的事情发生,程序默默地使用一个垃圾值并继续运行。
成员函数at
和许多其他函数可以抛出异常来提示错误。当一个程序抛出一个异常时,正常的、一条条语句的程序进程被中断。相反,一个特殊的异常处理系统控制程序。该标准为这个系统的实际工作方式提供了一些余地,但是您可以想象它会强制函数结束并破坏本地对象和参数,尽管这些函数不会向调用者返回值。相反,函数被强制结束,一次一个,一个特殊的代码块捕获异常。使用try
- catch
语句在程序中设置这些特殊代码块。catch
模块也被称为异常处理器。处理程序完成工作后,正常的代码执行会继续:
try {
throw std::runtime_error("oops");
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
}
当程序抛出一个异常时(用throw
关键字),它抛出一个值,称为异常对象,它可以是几乎任何类型的对象。按照惯例,异常类型,比如std::runtime_error
,继承自std::exception
类或者标准库提供的几个子类之一。第三方类库经常引入自己的异常基类。
异常处理程序还有一个对象声明,它有一个类型,处理程序只接受匹配类型的异常对象。如果没有一个异常处理程序有匹配的类型,或者如果你根本没有写任何处理程序,程序就会终止,就像清单 48-1 中发生的那样。本文的其余部分将详细研究异常处理的各个方面。
捕捉异常
一个异常处理器被称为捕捉一个异常。在一个try
的末尾写一个异常处理程序:try
关键字后面是一个复合语句(必须是复合的),后面是一系列处理程序。每个处理程序都以一个catch
关键字开始,后面是圆括号,括号中包含了异常处理程序对象的声明。括号后面是一个复合语句,它是异常处理程序的主体。
当异常对象的类型与异常处理程序对象的类型匹配时,处理程序被认为是匹配的,并且处理程序对象用异常对象初始化。处理程序声明通常是一个引用,这样可以避免不必要地复制异常对象。大多数处理程序不需要修改异常对象,所以处理程序声明通常是对const
的引用。“匹配”是当异常对象的类型与处理程序声明的类型或从处理程序声明的类型派生的类相同时,忽略处理程序是const
还是引用。
异常处理系统在抛出异常之前销毁它在语句的try
部分构造的所有对象,然后它将控制转移到处理程序,因此处理程序的主体正常运行,并且在整个try
- catch
语句结束后,也就是在语句的最后一个catch
处理程序结束后,控制随着语句恢复。按顺序尝试处理程序类型,第一个匹配者获胜。因此,您应该总是首先列出最具体的类型,然后列出基类类型。
基类异常处理程序类型匹配任何派生类型的异常对象。为了处理标准库可能抛出的所有异常,编写处理程序来捕捉std::exception
(在<exception>
中声明),这是所有标准异常的基类。清单 48-3 展示了std::string
类可以抛出的一些异常。通过键入不同长度的字符串来试用该程序。
import <cstdlib>;
import <exception>;
import <iostream>;
import <stdexcept>;
import <string>;
int main()
{
std::string line{};
while (std::getline(std::cin, line))
{
try
{
line.at(10) = ' '; // can throw out_of_range
if (line.size() < 20)
line.append(line.max_size(), '*'); // can throw length_error
for (std::string::size_type size(line.size());
size < line.max_size();
size = size * 2)
{
line.resize(size); // can throw bad_alloc
}
line.resize(line.max_size()); // can throw bad_alloc
std::cout << "okay\n";
}
catch (std::out_of_range const& ex)
{
std::cout << ex.what() << '\n';
std::cout << "string index (10) out of range.\n";
}
catch (std::length_error const& ex)
{
std::cout << ex.what() << '\n';
std::cout << "maximum string length (" << line.max_size() << ") exceeded.\n";
}
catch (std::exception const& ex)
{
std::cout << "other exception: " << ex.what() << '\n';
}
catch (...)
{
std::cout << "Unknown exception type. Program terminating.\n";
std::abort();
}
}
}
Listing 48-3.Forcing a string to Throw Exceptions
如果您键入包含 10 个或更少字符的行,line.at(10)
表达式将抛出std::out_of_range
异常。如果字符串多于 10 个字符,但少于 20 个字符,程序会尝试附加一个星号('*'
)的最大字符串长度重复,结果是std::length_error
。如果初始字符串超过 20 个字符,程序会尝试使用不断增长的大小来增加字符串的大小。最有可能的是,大小最终会超过可用内存,在这种情况下,resize()
函数将抛出std::bad_alloc
。如果你有很多很多的内存,下一个错误情况会迫使字符串大小达到string
支持的限制,然后尝试向字符串中添加另一个字符,这会导致push_back
函数抛出std::length_error
。(max_size
成员函数返回一个容器(比如std::string
)可以包含的最大元素数量。)
基类处理程序捕捉前两个处理程序错过的任何异常;特别是它抓住了std::bad_alloc
。what()
成员函数返回一个描述异常的字符串。字符串的确切内容因实现而异。任何重要的应用程序都应该定义自己的异常类,并对用户隐藏标准库异常。特别是,从what()
返回的字符串是实现定义的,不一定有用。捕捉bad_alloc
尤其棘手,因为如果系统内存不足,应用程序可能没有足够的内存在关闭前保存数据。你应该总是显式地处理bad_alloc
,但是我想演示一个基类的处理程序。
最后一个catch
处理程序使用省略号(...
)代替声明。这是一个匹配任何异常的无所不包的处理程序。如果使用它,它必须是 last,因为它匹配任何类型的每个异常对象。因为处理程序不知道异常的类型,所以它没有办法访问异常对象。这个包罗万象的处理程序打印一条消息,然后调用std::abort()
(在<cstdlib>
中声明),这将立即结束程序。因为std::exception
处理程序捕获所有标准库异常,所以并不真正需要最终的全部捕获处理程序,但是我想向您展示它是如何工作的。
抛出异常
一个抛出表达式抛出异常。表达式由关键字throw
后跟一个表达式组成,即异常对象。标准异常都接受一个string
参数,该参数成为从what()
成员函数返回的值。
throw std::out_of_range("index out of range");
标准库为自己的异常使用的消息是实现定义的,因此您不能依赖它们来提供任何有用的信息。
你可以在任何可以使用表达式的地方抛出异常。throw 表达式的类型是void
,这意味着它没有类型。类型void
不允许作为任何算术、比较或其他运算符的操作数。因此,实际上,throw
表达式通常单独用在表达式语句中。
您可以在 catch 处理程序中抛出异常,低级代码和库经常这样做。不使用throw
关键字,而是调用std::throw_with_nested()
,传递新的异常对象作为参数。throw_with_nested()
函数将您的异常对象与当前抛出的异常对象结合起来,并冒泡到下一个异常处理程序。正常情况下捕捉一个嵌套异常,但是如果你发现异常是嵌套的,处理程序必须一次剥离一个,如清单 48-4 所示。
import <exception>;
import <fstream>;
import <iomanip>;
import <iostream>;
import <stdexcept>;
void print_exception(const std::exception& e, int level = 0)
{
std::cerr << std::setw(level) << ' ' << "exception: " << e.what() << '\n';
try {
std::rethrow_if_nested(e);
} catch(const std::exception& e) {
// caught a nested exception
print_exception(e, level+1);
} catch(...) {}
}
int main()
{
std::string const filename{ "nonexistent file" };
std::ifstream file;
file.exceptions(std::ios_base::failbit);
try
{
file.open(filename);
}
catch (std::ios_base::failure const&)
{
std::throw_with_nested(std::runtime_error{"Cannot open: " + filename});
}
catch (...)
{
file.close();
throw;
}
}
Listing 48-4.Nested Exceptions
打开文件流将在后面介绍。这里只是一个例子,说明当处理程序可以向异常添加一些有用的信息时,抛出 I/O 异常的常见方式,特别是打开的文件的名称。嵌套异常的工作方式是每一层嵌套嵌入一个异常对象,而rethrow_if_nested()
实际上将该对象作为一个新的异常抛出。因此,处理程序递归地一次一层地取消对异常洋葱的感知。
如果您只想执行一些清理并重新抛出相同的异常,请使用不带任何表达式的throw
关键字。再次引发异常的常见情况是在一个无所不包的处理程序中。catch-all 处理程序执行一些重要的清理工作,然后传播异常,以便程序可以处理它。
程序栈
为了理解当一个程序抛出异常时会发生什么,你必须首先理解程序栈的性质,有时被称为执行栈。过程语言和类似的语言在运行时使用栈来跟踪函数调用、函数参数和局部变量。C++ 栈还有助于跟踪异常处理程序。
当程序调用一个函数时,程序将一个帧推到栈上。该帧包含指令指针和其他寄存器、函数的参数等信息,还可能包含一些函数返回值的内存。当一个函数启动时,它可能会在栈上为局部变量留出一些内存。每个局部作用域将一个新帧推送到栈上。(编译器可能能够为某些局部范围甚至整个函数优化掉一个物理框架。然而,从概念上讲,以下情况适用。)
当函数执行时,它通常会构造各种对象:函数参数、局部变量、临时对象等等。编译器跟踪函数必须创建的所有对象,这样当函数返回时,它可以正确地销毁它们。本地对象的销毁顺序与它们的创建顺序相反。
框架是动态的,也就是说,它们表示程序中的函数调用和控制流,而不是源代码的静态表示。因此,函数可以调用自己,每次调用都会在栈上产生一个新的框架,每个框架都有自己的所有函数参数和局部变量的副本。
当程序抛出异常时,正常的控制流停止,C++ 异常处理机制接管。异常对象被复制到一个安全的地方,离开执行栈。异常处理代码在栈中寻找一个try
语句。当它找到一个try
语句时,它依次检查每个处理程序的类型,寻找匹配。如果没有找到匹配,它将在栈中更靠后的位置寻找下一个try
语句。它会一直寻找,直到找到匹配的处理程序,或者搜索完所有帧。
当找到匹配时,它从执行栈中弹出帧,在每个弹出的帧中调用所有本地对象的析构函数,并继续弹出帧,直到到达处理程序。从栈中弹出帧也被称为展开栈。
展开栈后,异常对象初始化处理程序的异常对象,然后执行catch
体。在catch
体正常退出后,异常对象被释放,执行继续执行最后一个兄弟catch
块末尾后面的语句。
如果处理程序抛出异常,那么重新开始搜索匹配的处理程序。一个处理程序不能处理它抛出的异常,在同一个try
语句中它的兄弟处理程序也不能。
如果没有处理程序匹配异常对象的类型,就调用std::terminate
函数,中止程序。有些实现会在调用terminate
之前弹出栈并释放本地对象,但有些不会。
清单 48-5 可以帮助你想象当一个程序抛出和捕获一个异常时,程序内部发生了什么。
1 import <exception>;
2 import <iostream>;
3 import <string>;
4
5 /// Make visual the construction and destruction of objects.
6 class visual
7 {
8 public:
9 visual(std::string const& what)
10 : id_{serial_}, what_{what}
11 {
12 ++serial_;
13 print("");
14 }
15 visual(visual const& ex)
16 : id_{ex.id_}, what_{ex.what_}
17 {
18 print("copy ");
19 }
20 ~visual()
21 {
22 print("~");
23 }
24 void print(std::string const& label)
25 const
26 {
27 std::cout << label << "visual(" << what_ << ": " << id_ << ")\n";
28 }
29 private:
30 static int serial_;
31 int const id_;
32 std::string const what_;
33 };
34
35 int visual::serial_{0};
36
37 void count_down(int n)
38 {
39 std::cout << "start count_down(" << n << ")\n";
40 visual v{"count_down local"};
41 try
42 {
43 if (n == 3)
44 throw visual("exception");
45 else if (n > 0)
46 count_down(n - 1);
47 }
48 catch (visual ex)
49 {
50 ex.print("catch on line 50 ");
51 throw;
52 }
53 std::cout << "end count_down(" << n << ")\n";
54 }
55
56 int main()
57 {
58 try
59 {
60 count_down(2);
61 std::cout << "--------------------\n";
62 count_down(4);
63 }
64 catch (visual const ex)
65 {
66 ex.print("catch on line 66 ");
67 }
68 std::cout << "All done!\n";
69 }
Listing 48-5.Visualizing an Exception
类有助于显示对象何时以及如何被构造、复制和销毁。count_down
函数在其参数等于 3 时抛出异常,当其参数为正时调用自身。对于非正参数,递归停止。为了帮助您查看函数调用,它会在进入和退出函数时打印参数。
对count_down
的第一次调用不会触发异常,所以您应该看到本地visual
对象的正常创建和销毁。确切地写出程序应该打印的结果,如第 60 行( count_down(2)
) )。
从main
到count_down
的下一个调用(第 62 行)允许count_down
在抛出异常之前递归一次。所以count_down(4)
叫count_down(3)
。本地对象v
被构造在count_down(4)
的框架内,而v
的新实例被构造在count_down(3)
的框架内。然后创建并抛出异常对象。(参见图 48-1 。)
图 48-1。
引发异常时的程序栈
异常在count_down
内部被捕获,所以它的帧没有被弹出。然后异常对象被复制到ex
(第 48 行),异常处理程序开始。它打印一条消息,然后重新抛出原来的异常对象(第 51 行)。异常处理机制对待这个异常的方式与对待任何其他异常一样:弹出try
语句的框架,然后弹出count_down
函数的框架。本地物体被破坏(包括ex
和v
)。count_down
中的最后一条语句不执行。
栈被展开,调用count_down(4)
中的try
语句被找到,异常对象再次被复制到ex
的一个新实例中。(参见图 48-2 。)异常处理程序打印一条消息并重新引发原始异常。弹出count_down(4)
帧,将控制返回到main
中的try
语句。同样,count_down
中的最后一条语句不执行。
图 48-2。
再次引发异常后的程序栈
main
中的异常处理程序轮到它了,这个处理程序最后一次打印异常对象(第 66 行)。在处理程序打印一条消息,并且catch
主体到达它的结尾之后,本地异常对象和原始异常对象被销毁。然后在第 68 行继续正常执行。最终输出是
start count_down(2)
visual(count_down local: 0)
start count_down(1)
visual(count_down local: 1)
start count_down(0)
visual(count_down local: 2)
end count_down(0)
~visual(count_down local: 2)
end count_down(1)
~visual(count_down local: 1)
end count_down(2)
~visual(count_down local: 0)
--------------------
start count_down(4)
visual(count_down local: 3)
start count_down(3)
visual(count_down local: 4)
visual(exception: 5)
copy visual(exception: 5)
catch on line 50 visual(exception: 5)
~visual(exception: 5)
~visual(count_down local: 4)
copy visual(exception: 5)
catch on line 50 visual(exception: 5)
~visual(exception: 5)
~visual(count_down local: 3)
copy visual(exception: 5)
catch on line 66 visual(exception: 5)
~visual(exception: 5)
~visual(exception: 5)
All done!
标准异常
标准库定义了几种标准的异常类型。基类exception
在<exception>
头中声明。大多数其他异常类都在<stdexcept>
头中定义。如果您想定义自己的异常类,我建议从<stdexcept>
中的一个标准异常中派生出来。
标准异常分为两类(两个基类直接从exception
派生而来):
-
运行时错误(
std::runtime_error
)是您不能仅仅通过检查源代码来检测或防止的异常。它们产生于你可以预见,但无法预防的情况。 -
逻辑错误(
std::logic_error
)是程序员错误的结果。它们表示违反了前提条件、无效的函数参数以及程序员应该在代码中防止的其他错误。
<stdexcept>
中的其他标准异常类都源自这两个。大多数标准库异常都是逻辑错误。例如,out_of_range
继承自logic_error
。当索引超出范围时,at
成员函数和其他函数抛出out_of_range
。毕竟,您应该检查索引和大小,以确保向量和字符串的使用是正确的,并且不依赖于异常。当你犯了一个错误(我们都犯了错误)时,异常是为了让你的程序干净有序地关闭。
你的库引用告诉你哪些函数抛出哪些异常,比如at
可以抛出out_of_range
。许多函数也可能抛出其他未记录的异常,这取决于库和编译器的实现。然而,一般来说,标准库很少使用异常。相反,当您提供错误的输入时,大多数库会产生未定义的行为。I/O 流通常不抛出任何异常,但是您可以安排它们在发生严重错误时抛出异常,这将在下一节中解释。
I/O 异常
您在 Exploration 32 中学习了 I/O 流状态位。状态位很重要,但是反复检查它们很麻烦。特别是,许多程序无法检查输出流的状态位,尤其是在写入标准输出时。那只是普通的、老式的懒惰。幸运的是,C++ 为程序员提供了一条无需太多额外工作就能获得 I/O 安全性的途径:当 I/O 失败时,流可以抛出异常。
除了状态位,每个流还有一个异常掩码。异常掩码告诉流在相应的状态位改变值时抛出异常。例如,您可以在异常掩码中设置badbit
,并且永远不要为这种不太可能发生的情况编写显式检查。如果发生严重的 I/O 错误,导致badbit
被置位,那么流将抛出一个异常。你可以编写一个高级处理程序来捕捉异常并干净地终止程序,如清单 48-6 所示。
import <iostream>;
int main()
{
std::cin.exceptions(std::ios_base::badbit);
std::cout.exceptions(std::ios_base::badbit);
int x{};
try
{
while (std::cin >> x)
std::cout << x << '\n';
if (not std::cin.eof()) // failure without eof means invalid input
std::cerr << "Invalid integer input. Program terminated.\n";
}
catch(std::ios_base::failure const& ex)
{
std::cerr << "Major I/O failure! Program terminated.\n" <<
ex.what() << '\n';
std::terminate();
}
}
Listing 48-6.Using an I/O Stream Exception Mask
如您所见,异常类被命名为std::ios_base::failure
。还要注意一个新的输出流:std::cerr
。<iostream>
头实际上声明了几个标准的 I/O 流。到目前为止,我只用过cin
和cout
,因为这是我们所需要的。cerr
流是专用于错误输出的输出流。在这种情况下,将正常输出(到cout
)与错误输出(到cerr
)分开是很重要的,因为cout
可能会出现致命错误(比如磁盘已满),所以任何向cout
写入错误消息的尝试都是徒劳的。相反,程序将消息写入cerr
。不能保证写cerr
会成功,但至少有机会;例如,用户可能将标准输出重定向到一个文件(因此有遇到磁盘满错误的风险),同时允许错误输出出现在控制台上。
回想一下,当输入流到达输入的末尾时,它会在其状态掩码中设置eofbit
。虽然您也可以在异常掩码中设置这个位,但是我看不出您有什么理由要这样做。如果一个输入操作没有从流中读取任何有用的东西,流就会设置failbit
。流可能不读取任何内容的最常见原因是文件尾(eofbit
被设置)或输入格式错误(例如,当程序试图读取一个数字时,输入流中的文本)。同样,可以在异常掩码中设置failbit
,但是大多数程序依赖普通的程序逻辑来测试输入流的状态。异常是针对异常情况的,当从流中读取时,文件结束是正常现象。
当failbit
被设置时,循环结束,但是您必须进一步测试以发现failbit
是否被设置,这是因为正常的文件结束条件还是因为格式错误的输入。如果eofbit
也被设置,你就知道这个流已经结束了。否则,failbit
一定是由于输入格式错误。
如您所见,异常并不能解决所有的错误情况。因此,badbit
是异常掩码中唯一对大多数程序有意义的位,尤其是对输入流。如果输出流无法将整个值写入流中,它将设置failbit
。通常,这种故障是因为设置了badbit
的 I/O 错误而发生的,但是至少理论上有可能输出故障设置了failbit
而没有设置badbit
。在大多数情况下,任何输出失败都是警报的原因,所以您可能希望对输出流的failbit
和输入流的badbit
抛出一个异常。
std::cin.exceptions(std::ios_base::badbit);
std::cout.exceptions(std::ios_base::failbit);
自定义异常
异常通过从主控制流中移除异常条件来简化编码。对于许多错误情况,您可以也应该使用异常。例如,rational
级(最近出现在探索 41 中)到目前为止完全避免了被零除的问题。比调用未定义的行为(被零除时会发生这种情况)更好的解决方案是在分母为零时抛出异常。通过从一个标准异常基类派生来定义自己的异常类,如清单 48-7 所示。通过定义自己的异常类,rational
的任何用户都可以很容易地将其异常与其他异常区分开来。
export module rational;
import <stdexcept>;
import <string>;
export class rational
{
public:
class zero_denominator : public std::logic_error
{
public:
using std::logic_error::logic_error;
};
rational() : rational{0} {}
rational(int num) : numerator_{num}, denominator_{1} {}
rational(int num, int den) : numerator_{num}, denominator_{den}
{
if (denominator_ == 0)
throw zero_denominator{"zero denominator"};
reduce();
}
... omitted for brevity ...
};
Listing 48-7.Throwing an Exception for a Zero Denominator
注意zero_denominator
类是如何嵌套在rational
类中的。嵌套类是一个非常普通的类。除了名称之外,它与外部类没有任何关系(与 Java 内部类一样)。嵌套类不能对外部类中的私有成员进行特殊访问,外部类也不能对嵌套类名进行特殊访问。访问级别的常规规则决定了嵌套类的可访问性。一些嵌套类是私有帮助类,所以你可以在外部类定义的私有部分声明它们。在这种情况下,zero_denominator
必须是公共的,这样调用者就可以在异常处理程序中使用这个类。
要在外部类之外使用嵌套类名,必须使用外部类和嵌套类名,用范围运算符(::
)分隔。嵌套类名在外部类的范围之外没有意义。因此,嵌套类有助于避免名称冲突。它们还为在异常处理程序中看到该类型的读者提供了清晰的文档:
catch (rational::zero_denominator const& ex) {
std::cerr << "zero denominator in rational number\n";
}
找到 rational 类中所有其他需要检查零分母的地方,并添加适当的错误检查代码来抛出零分母。
所有的路都通向reduce()
,所以一种方法是检查一个零分母,并在那里抛出异常。你不必修改任何其他函数,甚至在构造器中的额外检查(如清单 48-6 所示)也是不必要的。清单 48-8 显示了reduce()
的最新实现。
void rational::reduce()
{
if (denominator_ == 0)
throw zero_denominator{"denominator is zero"};
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
int div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
Listing 48-8.Checking for a Zero Denominator in reduce()
当函数不抛出异常时
某些函数不应该抛出异常。例如,numerator()
和denominator()
函数只是返回一个整数。他们不可能抛出异常。如果编译器知道函数从不抛出异常,它可以生成更有效的目标代码。有了这些特定的函数,编译器可能会内联扩展这些函数来直接访问数据成员,所以理论上,这并不重要。但是也许你决定不内联这些函数(出于探索 31 中列出的任何原因)。您仍然希望能够告诉编译器,函数不能抛出任何异常。进入noexcept
资格赛。
为了告诉编译器一个函数不抛出异常,在函数参数之后添加noexcept
限定符(在const
之后,override
之前)。
int numerator() const noexcept;
如果你中断联系会怎么样?试试吧。写一个程序,调用一个被限定为 noexcept 的普通函数,但是抛出一个异常。尝试捕获main()
中的异常。会发生什么?
如果你的程序看起来像我清单 48-9 中的程序,那么catch
应该会捕捉到异常,但是它没有。编译器信任noexcept
,没有生成正常的异常处理代码。因此,当function()
抛出异常时,程序唯一能做的就是立即终止。
import <iostream>;
import <exception>;
void function() noexcept
{
throw std::exception{};
}
int main()
{
try {
function();
} catch (std::exception const& ex) {
std::cout << "Gotcha!\n";
}
}
Listing 48-9.Throwing an Exception from a noexcept Function
明智地使用noexcept
。如果函数a()
只调用被标记为noexcept
的函数,那么a()
的作者可能也会决定使用a() noexcept
。但是如果其中一个函数,比如说b()
,改变了,不再是noexcept
,那么a()
就有麻烦了。如果b()
抛出一个异常,程序会毫不客气地终止。所以只有在你能保证函数现在不会抛出异常,将来也永远不会改变来抛出异常的情况下,才使用noexcept
。所以numerator()
和denominator()
在rational
类中是noexcept
大概是安全的,默认和单参数构造器也是,但是我想不出还有什么成员函数可以是noexcept
。
系统错误
探索 14 引入了<system_error>
头来显示程序无法打开文件时的错误信息。<system_error>
的目的是提供一种可移植的方法来管理错误代码、条件和消息。它很好地支持 POSIX 标准错误代码,但是将实现留给了其他操作系统。因此,请阅读您的文档以了解您的操作系统的支持。
一个std::error_category
定义了你的系统支持的错误代码和消息。标准库定义了两种全局错误类别:一般错误和系统错误。对于 POSIX 错误,std::generic_category()
函数返回一个error_category
对象,对于实现定义的错误,std::system_category()
返回一个error_category
。
一个std::error_code
将一个低级错误代码表示为一个与特定error_category
相关的整数。从errno
(无std::
前缀)中获取整数错误号,在 C 头文件<cerrno>
中声明。您可以如下构建一个error_code
对象:
auto ec{ std::error_code(errno, std::system_category()) };
但是如果您只需要相关的文本消息,错误类别会使用message()
成员函数直接返回它:
std::cerr << std::system_category().message(errno) << '\n';
要抛出一个使用错误代码的异常,抛出system_error
异常。你可以传递一个error_code
对象或者传递一个errno
和一个error_category
作为参数给system_error
构造器。您还可以传递一个可选字符串,如文件名。清单 48-10 显示了一个愚蠢的程序试图打开一个不存在的文件,并在打开失败时抛出一个异常。
#include <cerrno>
import <fstream>;
import <iostream>;
import <string>;
import <system_error>;
std::size_t count_words(std::string const& filename)
{
std::ifstream file(filename);
if (not file)
throw std::system_error(errno, std::system_category(), filename);
std::size_t count{0};
std::string word;
while (file >> word)
++count;
return count;
}
int main()
{
try
{
std::cout << count_words("Not a Real File Name") << '\n';
}
catch (std::exception const& ex)
{
std::cerr << ex.what() << '\n';
}
}
Listing 48-10.Throwing system_error for a File-Open Error
非凡的建议
异常的基本机制很容易掌握,但是它们的正确使用却比较困难。应用程序程序员有三个不同的任务:捕捉异常、抛出异常和避免异常。
您应该编写程序来捕捉所有异常,甚至是意外的异常。一种方法是让你的main
程序在整个程序体中有一个主try
语句。在程序中,您可以使用有针对性的try
语句来捕捉特定的异常。离异常源越近,拥有的上下文信息就越多,就越能改善问题,或者至少为用户提供更多有用的信息。
这个最外层的try
语句捕捉其他语句遗漏的任何异常。这是在程序突然终止之前给出一个连贯且有用的错误信息的最后尝试。至少,告诉用户程序由于意外的异常而终止。
在事件驱动的程序中,比如 GUI 应用程序,异常更成问题。最外层的try
语句关闭程序,关闭所有窗口。大多数事件处理程序应该有自己的try
语句来处理特定菜单选择、击键事件等的异常。
在程序体中,避免异常比捕捉异常更好。使用at
成员函数来访问 vector 的元素,但是您应该编写代码,以便确信索引总是有效的。索引和长度异常是程序员出错的迹象。
编写低级代码时,对于大多数不应该发生的错误情况或者反映程序员错误的错误情况,抛出异常。有些错误情况特别危险。例如,在rational
类中,在reduce()
返回后,分母不应该为零或负数。如果当分母确实为零或负数时出现一个条件,则程序的内部状态是损坏的。如果程序试图正常关闭,保存所有文件,等等,它可能会在文件中写入错误的数据。最好立即终止并依靠最新的备份副本,这是您的程序在其状态已知良好时制作的。对于这种紧急情况,使用断言,而不是异常。
理想情况下,您的代码应该验证用户输入,检查向量索引,并确保在调用函数之前所有函数的所有参数都是有效的。如果有任何东西是无效的,你的程序可以用一个清晰、直接的信息告诉用户,并完全避免异常。当您的检查失败或您忘记检查某些条件时,异常是一个安全网。
一般来说,库应该抛出异常,而不是捕捉它们。应用程序更倾向于捕捉异常而不是抛出异常。随着程序变得越来越复杂,我将强调需要异常、抛出或捕捉的情况。
既然您已经知道了如何编写类、重载操作符和处理错误,那么在开始实现自己的全功能类之前,您只需要学习一些额外的操作符。下一篇文章回顾了一些熟悉的操作符,并介绍了一些新的操作符。
四十九、更多运算符
C++ 有很多运算符。很多很多。到目前为止,我已经介绍了大多数程序需要的基本操作符:算术、比较、赋值、下标和函数调用。现在是时候介绍更多了:额外的赋值操作符、条件操作符(就像在表达式中间有一个if
语句)和逗号操作符(最常用于for
循环)。
条件运算符
条件运算符是 C++ 运算符库中的一个唯一条目,是一个三元运算符,也就是说,一个采用三个操作数的运算符。
condition ? true-part : false-part
条件是一个布尔表达式。如果计算结果为真,整个表达式的结果就是真部分。如果条件为假,则结果为假部分。与if
语句一样,只评估一部分;跳过未被采用的分支。例如,以下语句是绝对安全的:
std::cout << (x == 0 ? 0 : y / x);
如果x
为零,则不计算y / x
表达式,并且永远不会被零除。条件运算符的优先级非常低,所以您经常会看到它写在括号内。条件表达式可以是赋值表达式的源。所以下面的表达式把 42 或 24 赋给了x
,这取决于test
是否为真。
x = test ? 42 : 24;
赋值表达式可以是条件表达式的真部分或假部分,即下面的表达式
x ? y = 1 : y = 2;
被解析为
x ? (y = 1) : (y = 2);
真部分和假部分是具有相同或兼容类型的表达式,也就是说,编译器可以自动将一种类型转换为另一种类型,确保整个条件表达式具有定义良好的类型。比如可以混合整数和浮点数;表达式结果是一个浮点数。如果x
为正数,以下语句将打印10.000000
:
std::cout << std::fixed << (x > 0 ? 10 : 42.24) << '\n';
不要使用条件运算符代替if
语句。如果可以选择,使用if
语句,因为语句几乎总是比条件表达式更容易阅读和理解。在if
语句不可行的情况下使用条件表达式。例如,在构造器中初始化数据成员不允许使用if
语句。虽然可以对复杂的条件使用成员函数,但也可以对简单的条件使用条件表达式。
例如,rational
类(最后一次出现在 Exploration 47 )将分子和分母作为构造器的参数。这个类确保它的分母总是正的。如果分母为负,则对分子和分母求反。在过去的探索中,我给reduce()
成员函数加载了额外的职责,比如检查一个零分母和一个负分母,以反转分子和分母的符号。这种设计的优点是集中了将有理数转换成标准形式所需的所有代码。另一种设计是分离责任,让构造器在调用reduce()
之前检查分母。如果分母为零,构造器抛出异常;如果分母为负,则构造器对分子和分母求反。这种另类的设计让reduce()
更简单,简单的函数比复杂的函数更不容易出错。清单 49-1 展示了如何使用条件操作符来实现这一点。
/// Construct a rational object from a numerator and a denominator.
/// If the denominator is zero, throw zero_denominator. If the denominator
/// is negative, normalize the value by negating the numerator and denominator.
/// @post denominator_ > 0
/// @throws zero_denominator
rational::rational(int num, int den)
: numerator_{den < 0 ? -num : num},
denominator_{den == 0 ? throw zero_denominator("0 denominator") :
(den < 0 ? -den : den)}
{
reduce();
}
Listing 49-1.Using Conditional Expressions in a Constructor’s Initializer
一个throw
表达式有类型void
,但是编译器知道它不返回,所以你可以把它作为条件表达式的一部分(或者两部分)来使用。整体表达式的类型是非抛出部分的类型(或者是void
,如果两部分都抛出异常)。
换句话说,如果den
是零,表达式的真部分抛出异常。如果条件为假,则执行假部分,这是另一个条件表达式,它评估den
的绝对值。分子的初始化器也测试den
,如果为负,它也否定分子。
像我一样,您可能会发现使用条件表达式会使代码更难阅读。条件运算符在 C++ 程序中被广泛使用,所以你必须习惯阅读它。如果您认为条件表达式太复杂,编写一个单独的私有成员函数来完成这项工作,并通过调用该函数来初始化成员,如清单 49-2 所示。
/// Construct a rational object from a numerator and a denominator.
/// If the denominator is zero, throw zero_denominator. If the denominator
/// is negative, normalize the value by negating the numerator and denominator.
/// @post denominator_ > 0
/// @throws zero_denominator
rational::rational(int num, int den)
: numerator_{den < 0 ? -num : num}, denominator_{init_denominator(den)}
{
reduce();
}
/// Return an initial value for the denominator_ member. This function is used
/// only in a constructor's initializer list.
int rational::init_denominator(int den)
{
if (den == 0)
throw zero_denominator("0 denominator");
else if (den < 0)
return -den;
else
return den;
}
Listing 49-2.Using a Function and Conditional Statements Instead of Conditional Expressions
当编写新代码时,使用您最喜欢的技术,但是要习惯于阅读两种编程风格。
短路运算符
C++ 允许你重载and
和or
操作符,但是你必须抵制诱惑。通过重载这些操作符,您就失去了它们的一个主要优点:短路。
回想一下 Exploration 12 中的内容,如果不需要的话,and
和or
运算符不会计算它们的右操作数。对于内置的操作符来说是这样,但是如果你重载它们就不是这样了。当你重载布尔操作符时,它们就变成了普通函数,C++ 总是在调用函数之前计算函数参数。因此,重载的and
和or
操作符的行为与内置操作符不同,这种差异使得它们的用处大大降低。
Tip
不要支配and
和or
操作符。
逗点算符
逗号(,
)有很多作用:它分隔函数调用中的参数、函数声明中的参数、声明中的声明符以及构造器的初始化列表中的初始化符。在所有这些情况下,逗号都是一个标点符号,也就是说,它是语法的一部分,只用来表示一个事物(论元、声明符等)在哪里。)结束,另一件事开始。它本身也是一个运算符,这是同一个符号的完全不同的用法。逗号作为运算符分隔两个表达式;它导致左边的操作数被求值,然后右边的操作数被求值,这成为整个表达式的结果。左侧操作数的值被忽略。
乍一看,这个操作符似乎有点没有意义。毕竟,写作的目的是什么,比如说,
x = 1 + 2, y = x + 3, z = y + 4
代替
x = 1 + 2;
y = x + 3;
z = y + 4;
逗号运算符并不意味着可以代替编写单独的语句。然而,有一种情况,当多个语句是不可能的,但是多个表达式必须被求值。我说的不是别人,正是for
循环。
假设您想要实现基于迭代器的search
算法。实现一个完全通用的算法需要一些你还没有学过的技术,但是你可以编写这个函数,使它可以使用迭代器,但是不需要验证它的参数是否是正确的迭代器。基本思想很简单,search
遍历搜索范围,试图找到与匹配范围中的元素相等的元素序列。它一次遍历搜索范围中的一个元素,测试是否从该元素开始匹配。如果是,它将返回一个指向匹配起点的迭代器。如果没有找到匹配,search
返回结束迭代器。若要检查匹配,请使用嵌套循环来比较两个范围中的连续元素。清单 49-3 展示了实现这个功能的一种方法。
auto search(auto first1, auto last1, auto first2, auto last2)
{
// s1 is the size of the untested portion of the first range
// s2 is the size of the second range
// End the search when s2 > s1 because a match is impossible if the
// remaining portion of the search range is smaller than the test range.
// Each iteration of the outer loop shrinks the search range by one,
// and advances the first1 iterator. The inner loop searches
// for a match starting at first1.
for (auto s1{last1-first1}, s2{last2-first2}; s2 <= s1; --s1, ++first1)
{
// Is there a match starting at first1?
auto f2{first2};
for (auto f1{first1};
f1 != last1 and f2 != last2 and *f1 == *f2;
++f1, ++f2)
{
// The subsequence matches so far, so keep checking.
// All the work is done in the loop header, so the body is empty.
}
if (f2 == last2)
return first1; // match starts at first1
}
// no match
return last1;
}
Listing 49-3.Searching for a Matching Subrange Using Iterators
粗体行演示了逗号运算符。第一个for
循环的初始化部分不调用逗号运算符。声明中的逗号只是声明符之间的分隔符。逗号运算符出现在循环的后迭代部分。因为for
循环的后迭代部分是一个表达式,所以不能使用多个语句来递增多个对象。相反,你必须在一个表达式中完成,因此需要逗号操作符。
另一方面,一些程序员喜欢避免使用逗号操作符,因为生成的代码可能难以阅读。重写清单 49-3 以便它不使用逗号运算符。你更喜欢哪个版本的功能? ________________ 清单 49-4 显示了我的不带逗号运算符的search
版本。
auto search(auto first1, auto last1, auto first2, auto last2)
{
// s1 is the size of the untested portion of the first range
// s2 is the size of the second range
// End the search when s2 > s1 because a match is impossible if the
// remaining portion of the search range is smaller than the test range.
// Each iteration of the outer loop shrinks the search range by one,
// and advances the first1 iterator. The inner loop searches
// for a match starting at first1.
for (auto s1{last1-first1}, s2{last2-first2}; s2 <= s1; --s1)
{
// Is there a match starting at first1?
auto f2{first2};
for (auto f1{first1}; f1 != last1 and f2 != last2 and *f1 == *f2; )
{
++f1;
++f2;
}
if (f2 == last2)
return first1; // match starts at first1
++first1;
}
// no match
return last1;
}
Listing 49-4.The search Function Without Using the Comma Operator
逗号运算符的优先级很低,甚至低于赋值运算符和条件运算符。例如,如果循环必须使对象前进 2 步,则可以使用带有逗号运算符的赋值表达式。
for (int i{0}, j{size-1}; i < j; i += 2, j -= 2) do_something(i, j);
顺便说一下,C++ 允许你重载逗号操作符,但是你不应该利用这个特性。逗号非常基本,C++ 程序员很快就掌握了它的标准用法。如果逗号没有它通常的含义,当你的代码的读者试图理解它时,他们会感到困惑、迷惑和困难。
算术赋值运算符
除了常见的算术运算符,C++ 还有将算术和赋值结合起来的赋值运算符:+=
、-=
、*=
、/=
、%=
。赋值操作符x += y
是x = x + y
的简写,这同样适用于其他特殊的赋值操作符。因此,如果x
具有数字类型,以下三个表达式都是等价的:
x = x + 1;
x += 1;
++x;
特殊赋值操作符的优点是x
只计算一次,如果x
是一个复杂的表达式,这将是一个很好的选择。如果data
有类型std::vector<int>
,你觉得下面两个等价表达中哪个更容易阅读和理解?
data.at(data.size() / 2) = data.at(data.size() / 2) + 10;
data.at(data.size() / 2) += 10;
清单 49-5 显示了rational
类的*=
的一个示例实现。
rational const& rational::operator*=(rational const& rhs)
{
numerator_ *= rhs.numerator();
denominator_ *= rhs.denominator();
reduce();
return *this
;
}
Listing 49-5.Implementing the Multiplication Assignment Operator
operator*=
的返回类型是引用rational&
。返回值是*this
。尽管编译器允许您使用任何返回类型和值,但约定是赋值运算符返回对对象的引用,即左值。即使你的代码从来不使用返回值,但很多程序员使用赋值的结果,所以不要用void
作为返回类型。另一方面,给赋值结果赋值会导致疯狂,所以返回一个 const lvalue 很有意义。
rational r;
while ((r += rational{1,10}) != 2) do_something(r);
通常,实现算术运算符,比如+
,最简单的方法是首先实现相应的赋值运算符。然后根据赋值操作符实现自由操作符,如清单 49-6 中的rational
类所示。
rational operator*(rational const& lhs, rational const& rhs)
{
rational result{lhs};
result *= rhs;
return result;
}
Listing 49-6.Reimplementing Multiplication in Terms of an Assignment Operator
实现了 /=
、 +=
、 -=
类的运算符 rational
。你可以用很多方式实现这些操作符。我建议将算术逻辑放在赋值操作符中,并重新实现/
、+
和-
操作符来使用赋值操作符,就像我对乘法操作符所做的那样。我的解决方案出现在清单 49-7 中。
rational const& rational::operator+=(rational const& rhs)
{
numerator_ = numerator() * rhs.denominator() + rhs.numerator() * denominator();
denominator_ *= rhs.denominator();
reduce();
return *this;
}
rational const& rational::operator-=(rational const& rhs)
{
numerator_ = numerator() * rhs.denominator() - rhs.numerator() * denominator();
denominator_ *= rhs.denominator();
reduce();
return *this;
}
rational const& rational::operator/=(rational const& rhs)
{
if (rhs.numerator() == 0)
throw zero_denominator{"divide by zero"};
numerator_ *= rhs.denominator();
denominator_ *= rhs.numerator();
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
reduce();
return *this;
}
Listing 49-7.Other Arithmetic Assignment Operators
因为reduce()
不再检查负分母,任何可能将分母变为负的函数都必须检查。因为分母总是正的,所以你知道operator+=
和operator-=
不会导致分母变成负的。只有operator/=
引入了那种可能性,所以只有那个函数需要检查。
递增和递减
让我们给rational
类添加递增(++
)和递减(--
)操作符。因为这些操作符修改对象,所以我建议将它们实现为成员函数,尽管 C++ 也允许使用自由函数。为类 rational
实现前缀递增运算符。在清单 49-8 中比较你的函数和我的函数。
rational const& rational::operator++()
{
numerator_ += denominator_;
return *this;
}
Listing 49-8.The Prefix Increment Operator for rational
我相信您可以在没有额外帮助的情况下实现减量操作符。像算术赋值操作符一样,前缀operator++
返回对象作为引用。
这就剩下后缀运算符了。实现操作符的主体很容易,只需要一行额外的代码。但是,您必须注意返回类型。后缀运算符不能简单地返回*this
,因为它们返回对象的原始值,而不是它的新值。因此,这些运算符不能返回引用。相反,它们必须返回一个普通的右值。
但是如何声明函数呢?一个类不能有两个名称(operator++
)和参数相同的独立函数。不知何故,你需要一种方法告诉编译器operator++
的一个实现是前缀,另一个是后缀。
解决方案是,当编译器调用自定义后缀递增或递减运算符时,它将整数0
作为额外的参数传递。后缀运算符不需要这个额外参数的值;它只是一个占位符,用来区分前缀和后缀。
因此,当您用一个类型为int
的额外参数声明operator++
时,您是在声明后缀运算符。声明运算符时,省略额外参数的名称。这告诉编译器,函数不使用参数,所以编译器不会用关于未使用函数参数的消息来打扰你。为 rational
实现后缀递增和递减运算符。清单 49-9 显示了我的解决方案。
rational rational::operator++(int)
{
rational result{*this};
numerator_ += denominator_;
return result;
}
rational rational::operator--(int)
{
rational result{*this};
numerator_ -= denominator_;
return result;
}
Listing 49-9.Postfix Increment and Decrement Operators
一旦我们的修复项目尘埃落定,请看清单 49-10 中新的、改进的rational
类定义。
export module rat;
import <iostream>;
import <stdexcept>;
/// Represent a rational number (fraction) as a numerator and denominator.
export class rational
{
public:
class zero_denominator : public std::logic_error
{
public:
using std::logic_error::logic_error;
};
rational() noexcept : rational{0} {}
rational(int num) noexcept : numerator_{num}, denominator_{1} {}
rational(int num, int den);
rational(double r);
int numerator() const noexcept { return numerator_; }
int denominator() const noexcept { return denominator_; }
float as_float() const;
double as_double() const;
long double as_long_double() const;
// optimization to avoid an unneeded call to reduce()
rational const& operator=(int) noexcept;
rational const& operator+=(rational const& rhs);
rational const& operator-=(rational const& rhs);
rational const& operator*=(rational const& rhs);
rational const& operator/=(rational const& rhs);
rational const& operator++();
rational const& operator--();
rational operator++(int);
rational operator--(int);
private:
/// Reduce the numerator and denominator by their GCD.
void reduce();
/// Reduce the numerator and denominator, and normalize the signs of both,
/// that is, ensure denominator is not negative.
void normalize();
/// Return an initial value for denominator_. Throw a zero_denominator
/// exception if @p den is zero. Always return a positive number.
int init_denominator(int den);
int numerator_;
int denominator_;
};
export rational abs(rational const& r);
export rational operator-(rational const& r);
export rational operator+(rational const& lhs, rational const& rhs);
export rational operator-(rational const& lhs, rational const& rhs);
export rational operator*(rational const& lhs, rational const& rhs);
export rational operator/(rational const& lhs, rational const& rhs);
export bool operator==(rational const& a, rational const& b);
export bool operator<(rational const& a, rational const& b);
export inline bool operator!=(rational const& a, rational const& b)
{
return not (a == b);
}
export inline bool operator<=(rational const& a, rational const& b)
{
return not (b < a);
}
export inline bool operator>(rational const& a, rational const& b)
{
return b < a;
}
export inline bool operator>=(rational const& a, rational const& b)
{
return not (b > a);
}
export std::istream& operator>>(std::istream& in, rational& rat);
export std::ostream& operator<<(std::ostream& out, rational const& rat);
Listing 49-10.The rational Class Definition
接下来的探索是你的第二个项目。现在,您已经了解了类、继承、运算符重载和异常,您已经准备好处理一些重要的 C++ 编码了。
五十、项目 2:定点数
项目 2 的任务是实现一个简单的定点数类。类使用整数类型表示定点数。小数点后的位数是一个固定的常量,四。例如,将数字 3.1415 表示为整数 31415,将 3.14 表示为 31400。您必须重载算术、比较和 I/O 运算符来维护定点虚构。
fixed
类
给类命名fixed
。它应该有以下公共成员:
value_type
基础整数类型的类型别名,如int
或long
。通过在整个fixed
类中使用value_type
,只需改变value_type
的声明,就可以轻松地在int
和long
之间切换。
places
A static const int
等于 4,或者小数点后的位数。通过使用命名常量而不是硬编码值 4,您可以很容易地在将来将该值更改为 2 或其他值。
places10
一个static const int
等于 places10
,或者为定点值的比例因子。用内部整数除以places10
得到真值。用一个数字乘以places10
将它缩放成一个整数,这个整数被fixed
对象存储在内部。
fixed()
默认构造器。
固定(值类型整数,值类型分数)
从整数部分和小数部分生成定点值的构造器。例如,要构造定点值 10.0020,请使用fixed{10, 20}
。
如果fraction < 0
抛出std::invalid_argument
。如果是fraction >= places10
,那么构造器应该丢弃右边的数字,舍入结果。比如fixed{3, 14159} == fixed{3, 1416}
和fixed{31, 415926} == fixed{31, 4159}
。
fixed(double val)
从浮点数生成定点值的构造器。将分数四舍五入,去掉多余的数字。由此,fixed{12.3456789} == fixed{12, 3456789} == fixed{12, 3457}
。
实现算术运算符、算术赋值运算符、比较运算符和 I/O 运算符。不要担心溢出。读取定点数时尽可能检查错误。一定要处理不带小数点的整数(42
)和带太多小数点的值(3.14159
)。
实现一个成员函数,将定点值转换为std::string
。
to_string()
将该值转换为字符串表示形式;比如 3.1416 变成"3.1416"
,–21 变成"-21.0000"
。
转换成整数意味着丢弃信息。为了让用户非常清楚,调用函数round()
,强调定点值必须四舍五入为整数。
round()
四舍五入到最接近的整数。如果小数部分正好是 5000,则四舍五入到最接近的偶数(银行家四舍五入)。一定要处理负数和正数。
其他有用的成员函数让您可以访问原始值(有利于调试、实现附加操作等)。)或定点值的部分:整数部分和小数部分。
integer()
只返回整数部分,不返回小数部分。
fraction()
只返回小数部分,不返回整数部分。分数部分始终在[ 0
,places10
]范围内。
在一个名为fixed
的模块中实现fixed
类。您可以决定是编写单独的接口和实现模块,还是编写单个模块文件。决定哪些成员函数应该是内联的(如果有的话),并确保在模块接口中定义所有的内联函数。完成后,仔细检查您的解决方案并进行一些测试,将您的结果与我的结果进行比较,您可以从本书的网站上下载。
如果你需要帮助测试你的代码,试着将你的fixed
模块与清单 50-1 中的测试程序链接起来。测试程序使用在test
模块中声明的test
和test_equal
功能。细节超出了本书的范围。用一个布尔参数调用test
。如果参数为真,则测试通过。否则,测试失败,并且test
打印消息。test_equal
函数接受两个参数,如果它们不相等,则打印一条消息。因此,如果程序没有产生输出,所有测试都通过了。
import <iostream>;
import <sstream>;
import <stdexcept>;
import test;
import fixed;
int main()
{
fixed f1{};
test_equal(f1.value(), 0);
test_equal(f1.to_string(), "0.0000");
fixed f2{1};
test_equal(f2.value(), 10000);
test_equal(f2.to_string(), "1.0000");
fixed f3{3, 14162};
test_equal(f3.value(), 31416);
fixed f4{2, 14159265};
test_equal(f4.value(), 21416);
test_equal(f2 + f4, f1 + f3);
test(f2 + f4 <= f1 + f3);
test(f2 + f4 >= f1 + f3);
test(f1 < f2);
test(f1 <= f2);
test(f1 != f2);
test(f2 > f1);
test(f2 >= f1);
test(f2 != f1);
test_equal(f2 + f4, f3 - f1);
test_equal(f2 * f3, f3);
test_equal(f3 / f2, f3);
f4 += f2;
test_equal(f3, f4);
f4 -= f1;
test_equal(f3, f4);
f4 *= f2;
test_equal(f3, f4);
f4 /= f2;
test_equal(f3, f4);
test_equal(-f4, f1 - f4);
test_equal(-(-f4), f4);
--f4;
test_equal(f4 + 1, f3);
f4--;
test_equal(f4 + 2, f3);
++f4;
test_equal(f4 + 1, f3);
f4++;
test_equal(f4, f3);
++f3;
test_equal(++f4, f3);
test_equal(f4--, f3);
test_equal(f4++, --f3);
test_equal(--f4, f3);
test_equal(f4 / f3, f2);
test_equal(f4 - f3, f1);
test_equal(f4.to_string(), "3.1416");
test_equal(f4.integer(), 3);
f4 += fixed{0,4584};
test_equal(f4, 3.6);
test_equal(f4.integer(), 3);
test_equal(f4.round(), 4);
test_equal(f3.integer(), 3);
test_equal((-f3).integer(), -3);
test_equal(f3.fraction(), 1416);
test_equal((-f3).fraction(), 1416);
test_equal(fixed{7,4999}.round(), 7);
test_equal(fixed{7,5000}.round(), 8);
test_equal(fixed{7,5001}.round(), 8);
test_equal(fixed{7,4999}.round(), 7);
test_equal(fixed{8,5000}.round(), 8);
test_equal(fixed{8,5001}.round(), 9);
test_equal(fixed{123,2345500}, fixed(123,2346));
test_equal(fixed{123,2345501}, fixed(123,2346));
test_equal(fixed{123,2345499}, fixed(123,2345));
test_equal(fixed{123,2346500}, fixed(123,2346));
test_equal(fixed{123,2346501}, fixed(123,2347));
test_equal(fixed{123,2346499}, fixed(123,2346));
test_equal(fixed{123,2346400}, fixed(123,2346));
test_equal(fixed{123,2346600}, fixed(123,2347));
test_equal(fixed{-7,4999}.round(), -7);
test_equal(fixed{-7,5000}.round(), -8);
test_equal(fixed{-7,5001}.round(), -8);
test_equal(fixed{-7,4999}.round(), -7);
test_equal(fixed{-8,5000}.round(), -8);
test_equal(fixed{-8,5001}.round(), -9);
test_equal(fixed{-3.14159265}.value(), -31416);
test_equal(fixed{123,456789}.value(), 1234568);
test_equal(fixed{123,4}.value(), 1230004);
test_equal(fixed{-10,1111}.value(), -101111);
std::ostringstream out{};
out << f3 << " 3.14159265 " << fixed(-10,12) << " 3 421.4 end";
fixed f5{};
std::istringstream in{out.str()};
test(in >> f5);
test_equal(f5, f3);
test(in >> f5);
test_equal(f5, f3);
test(in >> f5);
test_equal(f5.value(), -100012);
test(in >> f5);
test_equal(f5.value(), 30000);
test(in >> f5);
test_equal(f5.value(), 4214000);
test(not (in >> f5));
test_equal(fixed{31.4159265}, fixed{31, 4159});
test_equal(fixed{31.41595}, fixed{31, 4160});
bool okay{false};
try {
fixed f6{1, -1};
} catch (std::invalid_argument const&) {
okay = true;
} catch (...) {
}
test(okay);
test_exit();
}
Listing 50-1.Testing the fixed Class
如果你需要一个提示,我实现了fixed
以便它存储一个单一的整数,并从右开始隐含小数点位置places10
。因此,我将值 1 存储为 10000。加减法很容易。当乘或除时,你必须缩放结果。(更好的方法是在乘法之前缩放操作数,这可以避免一些溢出的情况,但是您必须小心不要损失精度。)
五十一、函数模板
您在 Exploration 25 中看到,重载的魔力让 C++ 实现了绝对值函数的改进接口。取而代之的是三个不同的名字(abs
、labs
和fabs
),C++ 对所有三个函数都有一个名字。重载对需要调用abs
函数的程序员有帮助,但对实现者帮助不大,实现者仍然必须编写三个外观和行为都相同的独立函数。如果库作者能写一次abs
函数而不是三次,那不是很好吗?毕竟,这三个实现可能是相同的,只是返回类型和参数类型不同。本文介绍了这种称为泛型编程的编程风格。
通用函数
有时,您希望为整数和浮点类型提供重载函数,但实现本质上是相同的。绝对值就是一个例子;对于任何类型T
,函数看起来都是一样的(我使用名称absval
,以避免与标准库的abs
混淆或冲突),如清单 51-1 所示。
T absval(T x)
{
if (x < 0)
return -x;
else
return x;
}
Listing 51-1.Writing an Absolute Value Function
将T
替换为int
,将T
替换为double
,或者使用任何其他数字类型。你甚至可以用rational
代替T
,而absval
功能仍然按照你期望的方式工作。那么,为什么要浪费宝贵的时间编写、重写、再重写同一个函数呢?通过对函数定义的简单添加,您可以将函数转换为通用函数,也就是说,可以与任何合适的类型T
一起工作的函数,这可以在清单 51-2 中看到。
template<class T>
T absval(T x)
{
if (x < 0)
return -x;
else
return x;
}
Listing 51-2.Writing a Function Template
第一行是关键。template
关键字意味着后面是一个模板,在这种情况下,是一个函数模板定义。尖括号分隔了以逗号分隔的模板参数列表。函数模板是根据参数类型T
创建函数的模式。在函数模板定义中,T
代表一个类型,可能是任何类型。absval
函数的调用者决定了将替代T
的模板参数。
定义函数模板时,编译器会记住该模板,但不会生成任何代码。编译器会一直等到你使用函数模板,然后生成一个真正的函数。可以想象一下,编译器获取模板的源文本,用模板参数(如int
)替换模板参数T
,然后编译结果文本。下一节将告诉您更多关于如何使用函数模板的内容。
使用函数模板
使用函数模板很容易,至少在大多数情况下是如此。只需调用absval
函数,编译器会根据函数参数类型自动确定模板参数。你可能需要一点时间来熟悉模板参数和模板实参的概念,它们与函数形参和函数实参有很大的不同。
在absval
的情况下,模板参数是T
,模板实参必须是类型。不能将类型作为函数参数传递,但模板是不同的。你在程序中并没有真正“传递”任何东西。模板魔术发生在编译时。编译器看到了absval
的模板定义,然后看到了absval
函数模板的调用。编译器检查函数参数的类型,并根据函数参数的类型确定模板参数。编译器用模板参数替换T
,并生成一个新的absval
函数实例,为模板参数类型定制。因此,在下面的例子中,编译器发现x
具有类型int
,所以它用int
替换T
。
int x{-42};
int y{absval(x)};
编译器生成一个函数,就像库实现者编写了以下代码一样:
int absval(int x)
{
if (x < 0)
return -x;
else
return x;
}
后来,在同一个程序中,也许你在一个rational
对象上调用absval
:
rational r{-420, 10};
rational s{absval(r)};
编译器生成一个新的absval
实例:
rational absval(rational x)
{
if (x < 0)
return -x;
else
return x;
}
在这个新的absval
实例中,<
操作符是接受rational
参数的重载操作符。求反操作符也是一个定制操作符,它接受一个rational
参数。换句话说,当编译器生成一个absval
的实例时,它通过编译源代码来完成,就像模板作者写的那样。
编写一个包含absval
函数模板定义和一些测试代码的示例程序,用各种参数类型调用absval
。让自己相信函数模板确实有效。在清单 51-3 中将你的测试程序与我的进行比较。
import <iostream>;
import rational; // Listing 49-10
template<class T>
T absval(T x)
{
if (x < 0)
return -x;
else
return x;
}
int main()
{
std::cout << absval(-42) << '\n';
std::cout << absval(-4.2) << '\n';
std::cout << absval(42) << '\n';
std::cout << absval(4.2) << '\n';
std::cout << absval(-42L) << '\n';
std::cout << absval(rational{42, 5}) << '\n';
std::cout << absval(rational{-42, 5}) << '\n';
std::cout << absval(rational{42, -5}) << '\n';
}
Listing 51-3.Testing the absval Function Template
编写函数模板
编写函数模板比编写普通函数更难。当你写一个像absval
这样的模板时,问题是你不知道T
实际上会是什么类型。所以,函数必须是通用的。编译器会阻止你使用某些类型的T
。模板体使用T
的方式隐含了它的限制。
特别是,absval
对T
施加了以下限制:
-
T
必须是可复制的。这意味着你必须能够复制一个类型为T
的对象,这样参数可以传递给函数,结果可以返回。如果T
是一个类类型,那么这个类必须有一个可访问的复制构造器,也就是说,复制构造器不能是私有的。 -
T
必须与0
可比使用<
运算符。您可能会重载<
操作符,或者编译器会将0
转换为T
或将T
转换为int
。 -
必须为
T
类型的操作数定义一元operator-
。结果类型必须是T
或者编译器可以自动转换成T
的类型。
内置的数值类型都符合这些要求。rational
类型也符合这些要求,因为它支持自定义操作符。举例来说,string
类型没有,因为当右边的操作数是整数时,它缺少比较运算符,并且它缺少一元求反(-
)运算符。假设你试图在一个string
上呼叫absval
。
std::string test{"-42"};
std::cout << absval(test) << '\n';
你认为会发生什么?
试试看。到底发生了什么?
编译器抱怨缺少std::string
的比较和求反运算符。使用模板时,传递有用的错误消息的一个困难是,是给出使用模板的行号,还是给出模板定义中的行号。有时候,你会两者兼得。有时,除非您尝试使用模板,否则编译器无法报告模板定义中的错误。它可以立即报告其他错误。仔细阅读清单 51-4 。
template<class T>
T add(T lhs, T rhs)
{
return lhs(rhs);
}
int main()
{
}
Listing 51-4.Mystery Function Template
错误是什么?
你的编译器会报告它吗?
因为编译器不知道类型T
,所以无法判断lhs(rhs)
是什么意思。可以定义一个表达式有效的类型,但是这可能与add
的函数名不匹配。我们知道我们想要对T
使用数值类型,所以lhs(rhs)
是愚蠢的。毕竟3(4)
是什么意思?
如何让你的编译器报告错误?
添加一行代码来使用模板。例如,将此添加到main
:
return add(0, 0);
现在,每个编译器都会在模板定义中报告不是真正的函数调用表达式。
模板参数
每当你在一个 C++ 程序中看到T
,很可能你正在看一个模板。向后查看源文件,直到找到模板头,也就是以template
关键字开始的声明部分。这就是你应该找到模板参数的地方。使用T
作为模板参数名仅仅是一个惯例,但是它的使用几乎是普遍的。使用class
来声明T
可能看起来有点奇怪,尤其是因为您已经看到了几个模板参数实际上不是一个类的例子。
有些程序员不使用class
来声明模板参数类型,而是使用另一个关键字typename
,在这个上下文中意思是一样的。typename
相对于class
的优势在于它避免了对非类类型的任何混淆。缺点是typename
在模板上下文中有不止一种用法,这会在更复杂的模板定义中混淆人类读者。学会阅读这两种风格,但我在编写自己的模板时更喜欢使用class
,而且在大多数 C++ 代码中class
比typename
出现得更频繁。
有时候,你会看到比T
更具体的参数名。如果模板有多个参数,那么每个参数都必须有一个惟一的名称,所以您肯定会看到除了T
之外的名称。例如,std::ranges::copy
算法是一个带有两个模板参数的函数模板:输入范围类型和输出迭代器类型。因此,copy
的定义可能类似于清单 51-5 。
template<class InputRange, class OutputIterator>
OutputIterator copy(InputRange range, OutputIterator output)
{
for (auto const& item : range)
*output++ = item;
return output;
}
Listing 51-5.One Way to Implement the copy Algorithm
很简单,不是吗?(真正的copy
函数更复杂,需要检查有效的参数并对某些类型进行优化。然而,在复杂性之下,可能是一个看起来像清单 51-5 的函数,尽管有不同的参数名。)
使用copy
算法时,编译器根据函数参数类型确定InputRange
和OutputIterator
的值。正如您在absval
中看到的,函数对模板参数的要求都是隐式的。因为InputRange
必须是一个范围,std::ranges::begin(range)
必须返回起始迭代器,std::ranges::end(range)
必须返回哨兵。起始迭代器必须满足输入迭代器的要求,即运算符*
返回一个项,operator++
推进迭代器,迭代器必须与 sentinel 具有可比性。OutputIterator
也必须以输出迭代器的方式实现*
和++
。
写一个 find
算法的简单实现。这个算法的范围形式提出了一些棘手的问题,所以让我们实现函数的迭代器形式。(好奇的话想一下find()
的区间版。它的返回类型是什么?模板参数是范围类型,std::ranges::begin()
返回一个迭代器,这是find()
想要的返回类型。但是如果没有找到这个值,find()
必须返回std::ranges::end()
的标记值,即使它有不同的类型。所以find()
必须将 sentinel 值转换成迭代器类型。在接下来的探索中,您将了解更多关于模板的知识,这将帮助您掌握这些问题。现在我们回到find()
的迭代器形式。)
模板有两个参数:InputIterator
和T
。该函数有三个参数。前两个类型为InputIterator
,指定了要搜索的范围。第三个参数的类型是T
,是要搜索的值。将您的解决方案与清单 51-6 进行比较。
template<class InputIterator, class T>
InputIterator find(InputIterator start, InputIterator end, T value)
{
for ( ; start != end; ++start)
if (*start == value)
return start;
return end;
}
Listing 51-6.Implementing the find Algorithm
许多标准算法本质上都很简单。现代的实现经过了大量的优化,手动优化代码的本质就是这样,结果通常与原始代码几乎没有相似之处,并且优化后的代码可能更难阅读。尽管如此,简单性仍然保留在标准库的架构中,它广泛依赖于模板。
模板参数
当编译器自动从函数参数中推导出模板参数时,模板是最容易使用的。然而,它不能总是这样做,所以你可能必须明确地告诉编译器你想要什么。例如,min
、max
和mixmax
标准算法的简单形式采用单个模板参数。清单 51-7 显示了min
功能的一种可能实现,以供参考。
template<class T>
T min(T a, T b)
{
if (b < a)
return b;
else
return a;
}
Listing 51-7.The std::min Algorithm
如果两个参数类型相同,编译器可以推导出所需的类型,一切都正常了。
int x{10}, y{20}, z{std::min(x, y)};
另一方面,如果函数参数类型不同,编译器就不能判断模板参数使用哪种类型。
int x{10};
long y{20};
std::cout << std::min(x, y); // error
为什么会这样?假设您编写了自己的函数作为非模板。
long my_min(long a, long b)
{
if (b < a)
return b;
else
return a;
}
编译器可以通过将x
从int
转换为long
来处理my_min(x, y)
。但是,作为模板,编译器不执行任何自动类型转换。编译器无法理解您的想法,也不知道您希望模板参数具有第一个函数参数或第二个函数参数的类型,或者有时是第一个,有时是第二个。相反,编译器要求你确切地写出你的意思。在这种情况下,您可以通过将所需的类型括在尖括号中来告诉编译器模板参数使用什么类型。
int x{10};
long y{20};
std::cout << std::min<long>(x, y); // okay: compiler converts x to type long
如果模板有多个参数,请用逗号分隔参数。例如,清单 51-8 显示了input_sum
函数,它从标准输入中读取项目,并通过+=
操作符累加它们。累加器的类型可以不同于项目类型。因为函数参数中没有使用 item 和 accumulator 类型,所以编译器无法推断出参数参数,所以您必须显式地提供它们。
import <iostream>;
template<class T, class U>
U input_sum(std::istream& in)
{
T x{};
U sum{0};
while (in >> x)
sum += x;
return sum;
}
int main()
{
long sum{input_sum<int, long>(std::cin)};
std::cout << sum << '\n';
}
Listing 51-8.Multiple Template Arguments
写一个函数,isprime,做一个函数模板,这样就可以对int
、short
或者long
参数使用同一个函数模板。该函数确定其参数是否为质数,即只能被 1 及其自身整除的数。将您的解决方案与清单 51-9 中的我的解决方案进行比较。
template<class T>
bool isprime(T n)
{
if (n < 2)
return false;
else if (n <= 3)
return true;
else if (n % 2 == 0)
return false;
else
{
for (T test{3}, limit{n / 2}; test < limit; test += 2)
if (n % test == 0)
return false;
return true
;
}
}
Listing 51-9.The isprime Function Template
缩写函数模板
编写某些模板的一个更短的方法是使用auto
关键字作为函数参数类型,类似于它可以用来定义局部变量的方式。对任何函数参数类型使用auto
会将函数变成函数模板。每个auto
参数就像添加一个模板参数。例如,您可以使用auto
重写清单 51-5 ,如清单 51-10 所示。
auto copy(auto range, auto output)
{
for (auto const& item : range)
*output++ = item;
return output;
}
Listing 51-10.Another Way to Implement the copy Algorithm
返回类型也是auto
,它告诉编译器从函数中的return
语句确定返回类型。在这种情况下,返回类型与output
参数的类型相同,这正是我们想要的。
对于copy
(清单 51-6 )的迭代器形式,使用auto
要困难得多,因为每个auto
函数参数都有一个单独的模板参数作为其类型。强制两个参数具有相同的类型是普通函数模板最容易做到的。但是,当一个函数模板的每个函数参数都有一个单独的模板参数时,缩写形式可能是编写函数模板的一种简洁方式。
声明和定义
我似乎不能停止谈论声明和定义。模板给这个情节带来了另一个转折。使用模板时,规则会发生变化。在使用函数模板之前,编译器必须看到的不仅仅是一个声明。编译器通常需要完整的函数模板定义。换句话说,如果你在一个模块中定义一个模板,那么这个模块必须包含这个函数模板的主体。假设您想在多个项目中共享isprime
函数。通常,您会将函数声明放在一个模块中,比如说prime
,并且您可能想要一个单独的模块实现。
然而,当你将isprime
转换成一个函数模板时,你必须将定义放在模块接口中,这样编译器就可以从模板中创建具体的函数,比如说,为isprime<int>
或isprime<long>
创建函数。
成员函数模板
在探索 36 中,我们为rational
类编写了三个几乎相同的函数:to_long_double
、to_double
和to_float
。它们都做同样的事情:在转换成目标类型后,将分子除以分母。每当有多个函数以相同的方式使用相同的代码做相同的事情时,就有了一个候选模板,如下所示:
template<class T, class R>
T convert(R const& r)
{
return static_cast<T>(r.numerator()) / r.denominator();
}
与任何函数模板一样,R
上唯一的要求是类型为R
的对象具有名为numerator()
和denominator()
的成员函数,并且这些函数具有适用于operator/
的返回类型(可以重载)。要使用convert
函数,您必须提供目标类型T
,作为显式模板参数,但是您可以让编译器从函数参数中推导出R
:
rational r{42, 10};
double d{ convert<double>(r) };
您可以从最右边的参数开始,省略编译器可以推导出的模板参数。正如您在本文前面所看到的,如果编译器可以推导出所有的参数,那么您可以完全省去尖括号。
函数模板也可以是成员函数。您可能更喜欢使用成员函数模板,而不是将rational
对象作为参数传递,如下所示:
rational r{42, 10};
double d{ r.convert<double>() };
成员函数模板避免了与其他可能被命名为convert
的自由函数的冲突。但是它也限制了你的函数的效用。作为一个非成员函数(也称为自由函数),它适用于任何看起来像有分子和分母的有理数类型的类型。即使是你没见过的类型,只要满足 convert()函数的基本限制,就可以了。但是作为一个成员函数,它不可避免地与您的 rational 类型而不是其他人的类型联系在一起。您将会看到标准 C++ 库有许多免费的函数,这些函数被设计用来处理各种用户编写的类型。这是非常好的设计。
泛型编程是一种强大的技术,随着您在接下来的几篇文章中对它了解得越来越多,您将会看到这种编程范式是多么的有表现力和有用。
五十二、类模板
一个类可以是一个模板,这使得它的所有成员都是模板。本书中的每个程序都使用了类模板,因为标准库的大部分都依赖于模板:标准 I/O 流、字符串、向量和映射都是类模板。这个探索着眼于简单的类模板。
参数化类型
考虑一个简单的point
类,它存储一个x
和y
坐标。图形设备驱动程序可能使用int
作为成员类型。
class point {
public:
point(int x, int y) : x_{x}, y_{y} {}
int x() const { return x_; }
int y() const { return y_; }
private:
int x_, y_;
};
另一方面,一个演算工具可能更喜欢使用double
。
class point {
public:
point(double x, double y) : x_{x}, y_{y} {}
double x() const { return x_; }
double y() const { return y_; }
private:
double x_, y_;
};
想象一下给point
类添加更多的功能:计算两个point
对象之间的距离,将一个point
围绕另一个旋转某个角度,等等。您想出的功能越多,您必须在两个类中复制的代码就越多。
如果您可以编写一次point
类,并对这两种情况和其他还没有想到的情况使用这个定义,您的工作不是更简单吗?拯救模板。清单 52-1 显示了point
类模板。
template<class T>
class point {
public:
point(T x, T y) : x_{x}, y_{y} {}
T x() const { return x_; }
T y() const { return y_; }
/// Move to absolute coordinates (x, y).
void move_to(T x, T y);
/// Add (x, y) to current position.
void move_by(T x, T y);
private:
T x_, y_;
};
template<class T>
void point<T>::move_to(T x, T y)
{
x_ = x;
y_ = y;
}
Listing 52-1.The point Class Template
正如函数模板一样,template
关键字引入了一个类模板。类模板是一种创建类的模式,您可以通过提供模板参数来创建类,例如point<int>
。
类模板的成员函数本身就是函数模板,使用相同的模板参数,除了你提供模板参数给类,而不是函数,正如你在point<T>::move_to
函数中看到的。编写 move_by
成员函数。将您的解决方案与清单 52-2 进行比较。
template<class T>
void point<T>::move_by(T x, T y)
{
x_ += x;
y_ += y;
}
Listing 52-2.The move_by Member Function
每次使用不同的模板参数时,编译器都会用新的成员函数生成一个新的类实例。也就是说,point<int>::move_by
是一个函数,point<double>::move_by
是另一个函数,这正是如果您手工编写函数会发生的情况。如果两个不同的源文件都使用point<int>
,编译器和链接器确保它们共享同一个模板实例。
参数化 rational 类
一个简单的point
类很容易。更复杂的东西呢,比如rational
类?假设有人喜欢你的rational
类,但是想要更精确。您决定将分子和分母的类型从int
改为long
。然后有人抱怨说rational
占用了太多内存,并要求使用short
作为基本类型的版本。您可以复制三份源代码,分别用于类型short
、int
和long
。或者您可以定义一个类模板,如清单 52-3 中简化的rational
类模板所示。
template<class T>
class rational
{
public:
using value_type = T;
rational() : rational{0} {}
rational(value_type num) : numerator_{num}, denominator_{1} {}
rational(value_type num, value_type den);
void assign(value_type num, value_type den);
rational const& operator +=(rational const& rhs);
rational const& operator -=(rational const& rhs);
rational const& operator *=(rational const& rhs);
rational const& operator /=(rational const& rhs);
template<class U>
U convert()
const
{
return static_cast<U>(numerator()) / static_cast<U>(denominator());
}
value_type const& numerator() const { return numerator_; }
value_type const& denominator() const { return denominator_; }
private:
void reduce();
value_type numerator_;
value_type denominator_;
};
template<class T>
rational<T>::rational(value_type num, value_type den)
: numerator_{num}, denominator_{den}
{
reduce();
}
template<class T>
void rational<T>::assign(value_type num, value_type den)
{
numerator_ = num;
denominator_ = den;
reduce();
}
template<class T>
bool operator==(rational<T> const& a, rational<T> const& b)
{
return a.numerator() == b.numerator() and
a.denominator() == b.denominator();
}
template<class T>
inline bool operator!=(rational<T> const& a, rational<T> const& b)
{
return not (a == b);
}
Listing 52-3.The rational Class Template
成员类型是一个有用的约定。许多使用模板参数作为某种从属类型的类模板在一个明确定义的名称下公开参数。例如,vector<char>::value_type
是其模板参数的成员类型,即char
。
看构造器的定义。当你在类模板之外定义一个成员时,你必须重复模板头。类型的全名包括模板参数,在本例中为rational<T>
。在类范围内,只使用类名,不使用模板参数。此外,一旦编译器看到完全限定的类名,它就知道它在类范围内,您也可以单独使用模板参数,这可以在参数声明中看到。在成员函数定义中,您可以调用任何其他成员函数并使用成员类型,例如value_type
。
因为名称T
已经被使用了,所以convert
成员函数(第 12 行)需要一个新名称作为它的模板参数。U
是一个常见的约定,只要你不做得太过分。多于两三个单字母参数,您开始需要更有意义的名称,只是为了帮助保持哪个参数与哪个模板匹配。
除了类模板本身,您还必须将所有支持 rational 类型的自由函数转换为函数模板。清单 52-3 通过只显示operator==
和operator!=
使事情变得简单。其他运算符的工作方式类似。
使用类模板
与函数模板不同,编译器不能推导出类模板的模板参数。这意味着您必须在尖括号内显式提供参数。
rational<short> zero{};
rational<int> pi1{355, 113};
rational<long> pi2{80143857L, 25510582L};
注意到什么熟悉的东西了吗?rational<int>
长得像vector<int>
吗?所有的集合类型,比如vector
和map
,都是类模板。标准库自始至终大量使用模板,在适当的时候,您会发现其他模板。
如果一个类模板有多个参数,用逗号分隔参数,如map<long, int>
所示。模板参数甚至可以是另一个模板,例如
std::vector<std::vector<int>> matrix;
从清单 52-3中的rational<>
开始,添加 I/O 操作符。(关于操作符的非模板版本,请参见清单 36-4。)编写一个简单的测试程序,读取rational
对象并将值回显到标准输出中,每行一个值。尝试将模板参数更改为不同的类型(short
、int
、long
)。您的测试程序可能看起来类似于清单 52-4 。
import <iostream>;
import rational;
int main()
{
rational<int> r{};
while (std::cin >> r)
std::cout << r << '\n';
}
Listing 52-4.Simple I/O Test of the rational Class Template
现在修改测试程序,只打印非零值。该程序看起来应该类似于清单 52-5 。
import <iostream>;
import rational;
int main()
{
static const rational<int> zero{};
rational<int> r{};
while (std::cin >> r)
if (r != zero)
std::cout << r << '\n';
}
Listing 52-5.Testing rational Comparison Operator
记住,使用旧的rational
类,编译器知道如何从整数构造一个rational
对象。因此,它可以将0
转换为rational(0)
,然后调用重载的==
操作符来比较两个 rational 对象。所以一切都很好。正确那么它为什么不起作用呢?
过载运算符
请记住,在前面的探索中,编译器不会为函数模板执行自动类型转换。这意味着编译器不会将一个int
转换成一个rational<int>
。为了解决这个问题,您必须添加一些额外的比较运算符,例如
template<class T> bool operator==(rational<T> const& lhs, T rhs);
template<class T> bool operator==(T lhs, rational<T> const& rhs);
template<class T> bool operator!=(rational<T> const& lhs, T rhs);
template<class T> bool operator!=(T lhs, rational<T> const& rhs);
对于所有的比较和算术运算符,以此类推。另一方面,你必须考虑这是否是你真正想要的。为了更好地理解这种方法的局限性,请继续尝试。你还不需要所有的比较操作符,只需要operator!=
,这样你就可以编译测试程序了。在添加了两个新的重载operator!=
函数之后,再次编译清单 52-5 ,以确保它能够工作。接下来,用模板参数long
编译测试程序。会发生什么?
编译器再一次抱怨它找不到任何适合!=
操作符的函数。问题是模板参数存在一个重载的!=
运算符,即类型long
,但是文字0
的类型是int
,而不是long
。您可以尝试通过为所有内置类型定义操作符来解决这个问题,但这很快就会失去控制。所以你的选择如下:
-
仅定义接受两个
rational
参数的运算符。强制调用者将参数转换成所需的rational
类型。 -
用三元组定义操作符:一个接受两个
rational
参数,另外两个混合了一个rational
和一个基本类型(T
)。 -
定义操作符来覆盖所有的基础—对于内置类型(
signed char
、char
、short
、int
、long
,加上一些我还没有覆盖的类型。因此,每个运算符需要 11 个函数。
您可能有兴趣了解 C++ 标准库如何解决这个问题。标准库中的类型中有一个类模板complex
,它代表一个复数。标准化委员会选择了第二种选择,即三个重载函数模板。
template<class T> bool operator==(complex<T> const& a, complex<T> const& b);
template<class T> bool operator==(complex<T> const& a, T const& b);
template<class T> bool operator==(T const& a, complex<T> const& b);
这个解决方案足够好,在本书的后面,您将学习一些技术来减少定义所有这些函数所涉及的工作量。
这个问题的另一个方面是字面上的0
。当你知道rational
的基本类型也是int
时,使用类型int
的字面量就可以了。如何在模板中表达一个通用的零?当测试零分母时,也会出现同样的问题。当你知道分母的类型是int
时,这就很容易了。使用模板时,您不知道模板的类型。回想一下清单 47-6,除法运算符检查零因子,在这种情况下抛出一个异常。如果你不知道类型T
,你怎么知道如何表示值零?你可以尝试使用文字0
并希望T
有一个合适的构造器(类型int
的单参数)。更好的解决方案是调用类型T
的默认构造器,如清单 52-6 所示。
template<class T>
rational<T> const& rational<T>::operator/=(rational const& rhs)
{
if (rhs.numerator() == T{})
throw zero_denominator("divide by zero");
numerator_ *= rhs.denominator();
denominator_ *= rhs.numerator();
if (denominator_ < T{})
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
reduce();
return *this;
}
Listing 52-6.Invoking a Default Constructor of a Template Parameter
如果类型T
是一个类类型,T{}
产生一个使用T
的默认构造器初始化的对象。如果T
是内置类型,T{}
的值为零(即0
、0.0
或false
)。在输入操作符中初始化局部变量有点复杂。
混合类型
如你所知,你可以给一个long
对象分配一个int
值,或者给分配一个值,反之亦然。因此,您应该能够为一个rational<long>
对象分配一个rational<int>
值,这似乎是合理的。试试看。编写一个简单的程序来执行混合基本类型的赋值。你的程序可能看起来有点像清单 52-7 ,但是许多其他程序同样合理。
import rational;
int main()
{
rational<int> little{};
rational<long> big{};
big = little;
}
Listing 52-7.Trying to Mix rational Base Types
当你编译你的程序时会发生什么?
新的rational
类模板的唯一赋值操作符是编译器的隐式操作符。它的参数类型是rational<T> const
,所以源表达式的基类型必须和赋值目标的基类型相同。使用成员函数模板可以很容易地解决这个问题。将以下声明添加到类模板中:
template<class U>
rational& operator=(rational<U> const& rhs);
在rational
类模板中,简单的名字rational
与rational<T>
意思相同。类的完整名称包括模板参数,因此构造器的正确名称是rational<T>
。因为rational
和rational<T>
意思相同,所以我可以在整个类模板定义中缩短构造器名和类型名的许多其他用法。但是赋值运算符的参数是rational<U>
。它使用了完全不同的模板参数。使用这个赋值操作符,您可以在一个赋值语句中自由混合不同的rational
类型。
写出赋值运算符的定义。不要担心将大值赋给小值可能会导致溢出。这是一个困难的问题,而且会分散对手头主要任务的注意力,主要任务是练习编写类模板和函数模板。将您的解决方案与清单 52-8 进行比较。
template<class T>
template<class U>
rational<T>& rational<T>::operator=(rational<U> const& rhs)
{
assign(rhs.numerator(), rhs.denominator());
return *this;
}
Listing 52-8.Defining the Assignment Operator Function Template
第一个模板头告诉编译器关于rational
类模板的信息。下一个模板头告诉编译器关于赋值运算符函数模板的信息。注意,编译器将能够从赋值源(rhs
)的类型中推导出U
的模板参数。在将这个操作符添加到rational
类模板之后,您现在应该能够让您的测试程序工作了。
添加一个成员模板构造器,其工作方式类似于赋值操作符。换句话说,给rational
添加一个看起来像复制构造器但实际上不是的构造器。复制构造器只复制相同类型的对象,即rational<T>
。这个新的构造器用不同的基本类型复制 rational 对象,rational<U>
。将您的解决方案与清单 52-9 进行比较。
template<class T>
template<class U>
rational<T>::rational(rational<U> const& copy)
: numerator_{copy.numerator()}, denominator_{copy.denominator()}
{}
Listing 52-9.Defining a Member Constructor Template
请注意模板头是如何堆叠的。首先是类模板头,然后是构造器模板头。通过完成所有操作符来完成 rational
类。新的课程太大了,这里不包括,但是你可以从书的网站下载完整的模块。
模板变量
变量也可以是模板。想象一下你期望一个模板变量定义如何工作。<numbers>
头定义了常用数学常量的几个模板常量,比如 π 、 e (自然对数底)、√2 等等。标准使用的惯例是用后缀_v
定义模板名,然后去掉后缀,用双模板参数实例化模板,例如:
template<class T> constexpr T pi_v = 3.141592653589793238462643383279502884L;
constexpr double pi = pi_v<double>;
为 rational
定义一个 pi
模板。它实际上比浮点模板更棘手,因为浮点模板可以根据需要依靠编译器将long double
常量转换为double
或float
。对于rational
,如果你试图定义一个需要long long
模板参数的近似值,那么这个参数对于较小的参数类型就不起作用了。所以现在,保持简单,使用31416
和10000
,这适用于short
和更大的类型。将您的变量定义与清单 52-10 进行比较。
template<class T>
inline const rational<T> pi{ 31416, 10000 };
Listing 52-10.Defining a Variable Template
因为双参数构造器不是constexpr
,所以pi
变量不能是constexpr
。但是它可以是inline
,就像一个inline
函数。编译器试图立即使用变量值,而不是从内存中获取它。
使用模板和类型参数编程打开了编程能力和灵活性的新世界。模板允许你写一次函数或类,并允许编译器为不同的模板参数生成实际的函数和类。然而,有时一种尺寸不能适合所有人,你必须允许例外。下一篇文章将介绍如何通过编写模板特化来做到这一点。
五十三、模板特化
也许 C++ 最强大的特性是能够编写一个模板,然后多次使用该模板,每次使用不同的模板参数。为规则开辟例外的能力放大了这种力量。也就是说,您可以告诉编译器对大多数模板参数使用一个模板,只是对于某些参数类型,它应该使用不同的模板定义。这个探索引入了这个特性。
实例化和特化
模板术语很复杂。当你使用一个模板时,它被称为实例化模板。一个模板实例是编译器通过将模板参数应用于模板定义而创建的一个具体函数或类。模板实例的另一个名字是特化。因此,rational<int>
是模板rational<>
的特化。
因此,特化是一组特定模板参数的模板的实现。C++ 允许您为一组特定的模板参数定义一个定制的专用化;也就是说,您可以为模板设定的规则创建一个例外。当您定义特化时——而不是让编译器为您实例化模板——它被称为显式特化。因此,编译器自动创建的特化将是一个隐式特化。(显式特化也被称为完全特化,其原因将在下一次探索中变得清楚。)
例如,标准库的<type_traits>
模块支持许多描述、表征和查询类型能力的类模板。让我们从一个非常简单的模板is_void<>
开始,它简单地表明它的模板参数是否是void
类型。清单 53-1 显示了一个可能的实现。主模板从std::false_type
继承而来,void
类型的特化从std::true_type
派生而来。
template<class T>
class is_void : public std::false_type
{};
template<>
class is_void<void> : public std::true_type
{};
Listing 53-1.The is_void Class Template
当你编写自己的模板类时,你可以使用is_void<T>::value
来确定类型T
是否是void
类型。如您所见,显式特化以template<>
开始(注意空的尖括号)。接下来是定义。请注意,类名是完整的专用模板名:is_void<void>
。编译器就是这样知道你在专攻什么的。初始模板定义称为主模板,以区别于模板特化。
您的显式特化完全替换了该模板参数的模板声明;如果模板采用多个参数,您必须为每个参数提供一个特定的值)。一般来说,完全特化将实现相同的成员,只是方式不同,但这是惯例,而不是语言规则。有时,特化可能与主模板非常不同。
假设一个客户喜欢你的rational
类模板,但是想在他们自己的模板中使用它,有时他们的模板参数类型是void
,所以他们想让rational<void>
做一些有用的事情。你不能有一个类型为void
的数据成员,所以编译器会拒绝rational<void>
,除非你写一个显式的特化。什么有意义?
我能想到的就是表示数值 0/1。写一个明确的特殊化为 rational<void>
。清单 53-2 展示了一种编写它的方法。
import rational;
template<>
class rational<void>
{
public:
using value_type = void;
rational() {}
int numerator() const { return 0; }
int denominator() const { return 1; }
};
Listing 53-2.Specializing rational<void>
您不需要实现reduce()
或类似的东西,因为rational<void>
只有一个值,即零。numerator()
和denominator()
函数总是返回相同的值。这不是一个特别有用的类,但是它展示了一个特化可能与主模板非常不同。
自定义比较器
map
容器允许您提供一个定制的比较器。默认行为是map
使用模板类std::less<>
,它是一个使用<
操作符来比较键的仿函数。如果你想存储一个无法与<
相比的类型,可以专门为你的类型定制std::less
。例如,假设您有一个person
类,它存储一个人的姓名、地址和电话号码。你想在一个按名字排序的map
中存储一个person
。你需要做的就是编写一个模板特化std::less<person>
,如清单 53-3 所示。
import <functional>;
import <iostream>;
import <map>;
import <string>;
import <string_view>;
class person {
public:
person() : name_{}, address_{}, phone_{} {}
person(std::string_view name,
std::string_view address,
std::string_view phone)
: name_{name}, address_{address}, phone_{phone}
{}
std::string const& name() const { return name_; }
std::string const& address() const { return address_; }
std::string const& phone() const { return phone_; }
private:
std::string name_, address_, phone_;
};
namespace std {
template<>
struct less<person> {
bool operator()(person const& a, person const& b) const {
return a.name() < b.name();
}
};
}
int main()
{
std::map<person, int> people;
people[person{"Ray", "123 Erewhon", "555-5555"}] = 42;
people[person{"Arthur", "456 Utopia", "123-4567"}]= 10;
std::cout << people.begin()->first.name() << '\n';
}
Listing 53-3.Specializing std::less to Compare person Objects by Name
您可以特化在std
名称空间中定义的类模板(而不是函数模板),但是您不能向std
添加新的声明。在<functional>
模块中声明了std::less
模板。这个模块为所有的关系和等式操作符定义了比较器模板,除此之外还有很多。有关详细信息,请查阅语言参考资料。现在重要的是std::less
主模板是什么样子,也就是 C++ 在找不到显式特化(比如std::less<person
>)时使用的主模板。编写一个类模板less
的定义,它将作为一个主模板,用<
操作符来比较任何可比较的对象。将您的解决方案与清单 53-4 进行比较。
template<class T>
struct less
{
bool operator()(T const& a, T const& b) const { return a < b; }
};
Listing 53-4.The Primary std::less Class Template
如果你能找到源代码,看看你的标准库的<functional>
模块。这可能比列出 53-4 更复杂,但你应该能找到你能识别和理解的东西。
特化函数模板
您可以特化一个函数模板,但是您应该更喜欢重载而不是模板。比如继续用absval
(探索 50 )的模板形式。假设您有一个任意精度的整数类,integer
,并且它有一个有效的绝对值函数(也就是说,它只是清除了符号位,所以没有必要进行比较)。你想用有效的方法取integer
的绝对值,而不是absval
的模板形式。尽管 C++ 允许您特化absval<>
函数模板,但更好的解决方案是覆盖absval
函数(不是模板):
integer absval(integer i)
{
i.clear_sign_bit();
return i;
}
当编译器看到对absval
的调用时,它会检查参数的类型。如果类型与非模板函数中使用的参数类型匹配,编译器会安排调用该函数。如果它不能匹配实参类型和形参类型,它就检查模板函数。精确的规则很复杂,我将在本书的后面讨论它们。现在,只要记住编译器更喜欢非模板函数而不是模板函数,但是如果它不能在实参类型和非模板函数的形参类型之间找到良好的匹配,它将使用模板函数而不是非模板函数。
然而,有时候你不得不写一个模板函数,即使你只是想重载absval
函数。例如,假设您想改进rational<T>
类模板的绝对值函数。没有必要将整个值与零进行比较;只比较分子,避免不必要的乘法。
template<class T>
rational<T> absval(rational<T> const& r)
{
if (r.numerator() < 0) // to avoid unnecessary multiplications in operator<
return -r;
else
return r;
}
当你调用absval
时,以通常的方式给它传递一个参数。如果传递一个int
、double
或其他内置的数值类型,编译器会实例化原始的函数模板。如果你传递一个integer
对象,编译器调用重载的非模板函数,如果你传递一个rational
对象,编译器实例化重载的函数模板。
特征
在本文的前面,我向您介绍了<type_traits>
模块,它有很多检查类型的方法。您还看到了特征模板的另一个例子:std::numeric_limits
。<limits>
模块定义了一个名为std::numeric_limits
的类模板。主模板相当枯燥,说该类型的精度为零,基数为零,等等。这个模板有意义的唯一方式是将其特化。因此,<limits>
模块也为所有内置类型定义了模板的显式特化。因此,您可以通过调用std::numeric_limits<int>::min()
来发现最小的int
,或者用std::numeric_limits<double>::radix
来确定double
的浮点基数,以此类推。每个特化都声明相同的成员,但是具有该特化特有的值。(注意,编译器不会强制每个特化声明相同的成员。C++ 标准为numeric_limits
规定了这个要求,正确实现标准取决于库作者,但是编译器不提供任何帮助。)
您可以在创建数值类型时定义自己的特化,比如rational
。定义模板的模板涉及到一些困难,我将在下一次探索中介绍,所以现在,回到清单 49-10 和老式的非模板rational
类,它硬编码int
作为基本类型。清单 53-5 展示了如何为这个rational
类特化numeric_limits
。
namespace std {
template<>
class numeric_limits<rational>
{
public:
static constexpr bool is_specialized{true};
static constexpr rational min() noexcept {
return rational(numeric_limits<int>::min());
}
static constexpr rational max() noexcept {
return rational(numeric_limits<int>::max());
}
static rational lowest() noexcept { return -max(); }
static constexpr int digits{ 2 * numeric_limits<int>::digits };
static constexpr int digits10{ numeric_limits<int>::digits10 };
static constexpr int max_digits10{ numeric_limits<int>::max_digits10 };
static constexpr bool is_signed{ true };
static constexpr bool is_integer{ false };
static constexpr bool is_exact{ true };
static constexpr int radix{ 2 };
static constexpr bool is_bounded{ true };
static constexpr bool is_modulo{ false };
static constexpr bool traps{ std::numeric_limits<int>::traps };
static rational epsilon() noexcept
{ return rational{1, numeric_limits<int>::max()-1}; }
static rational round_error() noexcept
{ return rational{1, numeric_limits<int>::max()}; }
// The following are meaningful only for floating-point types.
static constexpr int min_exponent{ 0 };
static constexpr int min_exponent10{ 0 };
static constexpr int max_exponent{ 0 };
static constexpr int max_exponent10{ 0 };
static constexpr bool has_infinity{ false };
static constexpr bool has_quiet_NaN{ false };
static constexpr bool has_signaling_NaN{ false };
static constexpr float_denorm_style has_denorm {denorm_absent};
static constexpr bool has_denorm_loss {false};
// The following are meant only for floating-point types, but you have
// to define them, anyway, even for nonfloating-point types. The values
// they return do not have to be meaningful.
static constexpr rational infinity() noexcept { return max(); }
static constexpr rational quiet_NaN() noexcept { return rational{}; }
static constexpr rational signaling_NaN() noexcept { return rational{}; }
static constexpr rational denorm_min() noexcept { return rational{}; }
static constexpr bool is_iec559{ false };
static constexpr bool tinyness_before{ false };
static constexpr float_round_style round_style{ round_toward_zero };
};
} // namespace std
Listing 53-5.Specializing numeric_limits for the rational Class
这个例子有一些新的东西。它们现在并不重要,但是在 C++ 中,你必须把所有微小的细节都处理好,否则编译器会发出严厉的反对。从namespace std
开始的第一行是如何在标准库中特化模板。不允许向标准库添加新名称,但是允许特化标准库已经定义的模板。注意名称空间的左花括号,在清单的最后一行有相应的右花括号。(这个话题将在探索 56 中更深入的讨论。)
成员函数的名字和体之间都有noexcept
。这告诉编译器该函数不会抛出任何异常(回想一下 Exploration 48 )。
constexpr
说明符类似于const
,但是它告诉编译器该函数在编译时是可调用的。为了让一个函数成为constexpr
,编译器强加了一些限制。它调用的任何函数也必须是constexpr
。函数参数和返回类型必须是内置的或者可以用constexpr
构造器构造的类型。如果违反了任何限制,则不能声明该功能constexpr
。这样,gcd()
函数不能是constexpr
,所以reduce()
不能是constexpr
,所以双参数构造器不能是constexpr
。能够编写一个在编译时被调用的函数的价值是极其有用的,我们将在未来返回到constexpr
。
Tip
第一次写模板的时候,从非模板版本开始。调试非模板函数或类要容易得多。一旦你得到了非模板版本的工作,然后把它变成一个模板。
模板特化还有许多其他用途,但是在我们得意忘形之前,下一篇文章将研究一种特殊的特化,其中您的特化仍然需要模板参数,称为部分特化。
二十四、部分模板特化
显式特化要求您为每个模板参数指定一个模板参数,在模板头中不留下任何模板参数。但是,有时您希望只指定一些模板参数,在头中保留一个或多个模板参数。C++ 让您可以做到这一点,甚至更多,但只是针对类模板,正如本文所描述的。
退化对
标准库在<utility>
头中定义了std::pair<T, U>
类模板。这个类模板是一对对象的简单持有者。模板参数指定了这两个对象的类型。清单 54-1 描述了这个简单模板的通用定义。(为了便于管理,我省略了一些涉及更高级编程技术的成员。)
template<class T, class U>
struct pair
{
using first_type = T;
using second_type = U;
T first;
U second;
pair();
pair(T const& first, U const& second);
template<class T2, class U2>
pair(pair<T2, U2> const& other);
};
Listing 54-1.The pair Class Template
记住关键字struct
的意思和class
一样。不同的是,默认的访问级别是public
。很多简单的类都用struct
,但是我喜欢一直用class
,只是为了不变。但是标准用struct
描述std::pair
,所以我给清单 54-1 选了同样的。甚至当使用struct
关键字定义时,我仍然称类型为“类”,因为它是。
正如您所看到的,pair
类模板并没有做多少事情。std::map
类模板可以使用std::pair
来存储键和值。少数函数,如std::equal_range
,为了返回两条信息,返回一个pair
。换句话说,pair
是标准库的一个有用的部分,尽管有些枯燥。
如果 T
或者 U
是 void
会怎么样?
虽然void
已经到处出现,通常作为函数的返回类型,但我并没有过多讨论。void
类型表示“无类型”这对于从不返回值的函数返回很有用,但是你不能用void
类型声明对象,编译器也不允许你使用void
作为数据成员。因此,pair<int, void>
导致了一个错误。
随着你开始越来越多地使用模板,你会发现自己处于不可预知的情况。一个模板可能包含一个模板,这个模板可能包含另一个模板,突然你发现一个模板,比如pair
,正在用你以前从未想象过的模板实参进行实例化,比如void
。为了完整起见,让我们为允许一两个void
模板参数的pair
添加特化。只有当模板参数是用户定义的类型时,标准才允许库模板的特化。因此,为void
类型指定std::pair
会导致未定义的行为。所以我们会特化自己的pair
类模板,而不是标准库中的std::pair
模板。
写一个明确的特殊化为 pair<void, void>
。它不能存储任何东西,但是你可以声明类型为pair<void, void>
的对象。为了测试您的解决方案,编译器需要首先看到主模板,然后是特化,所以请记住在您的测试代码中包含这两者。将您的解决方案与清单 54-2 进行比较。
template<>
struct pair<void, void>
{
using first_type = void;
using second_type = void;
pair(pair const&) = default;
pair() = default;
pair& operator=(pair const&) = default;
};
Listing 54-2.Specializing pair<> for Two void Arguments
构造器无关紧要,模板特化不能定义任何数据成员,所以这种特化是基本的,依赖于编译器自己对构造器和赋值操作符的默认定义。更困难的是一个论点的情况。对于该对的另一部分,您仍然需要一个模板参数。这需要部分专业化。
部分专业化
当你编写一个模板特化,它涉及到一些,但不是全部的模板参数时,它被称为部分特化。一些程序员称显式特化为完全特化,以帮助区别于部分特化。部分特化是显式的,所以短语完全特化更具描述性,我将在本书的其余部分使用它。
从一个模板头开始部分特化,这个模板头列出了您没有特化的模板参数。然后定义专业化。与完全特化一样,通过列出所有模板参数来命名您正在特化的类。一些模板参数依赖于特化的参数,一些模板参数固定有特定的值。这就是为什么这种专业化是片面的。
与完全特化一样,特化的定义完全取代了一组特定模板参数的主模板。按照惯例,您保持相同的接口,但是实际的实现取决于您。
如果第一个模板参数是void
,清单 54-3 显示了pair
的部分特化。
template<class U>
struct pair<void, U>
{
typedef void first_type;
typedef U second_type;
U second;
pair() = default;
pair(pair const&) = default;
pair(U const& second) : second{second} {}
template<class U2>
pair(pair<void, U2> const& other);
};
Listing 54-3.Specializing pair for One void Argument
在清单 54-3 、的基础上,写一个 pair
的局部特殊化,带一个 void
第二个自变量。将您的解决方案与清单 54-4 进行比较。
template<class T>
struct pair<T, void>
{
typedef T first_type;
typedef void second_type;
T first;
pair() = default;
pair(pair const&) = default;
pair(T const& first) : first{first} {}
template<class T2>
pair(pair<T2, void> const& other);
};
Listing 54-4.Specializing pair for the Other void Argument
不管是否存在任何部分或完整的特化,您仍然以同样的方式使用pair
模板:总是使用两个类型参数。编译器检查这些模板参数,并确定使用哪个特化。
部分模板特化的模板参数不必是模板本身的参数。它们可以是任何被特化的模板的任何参数。例如,清单 53-5 显示了std::numeric_limits<rational>
的完全特化,假设 rational 被硬编码为使用一个int
类型。但是更有用的是rational<>
类模板。在这种情况下,您将需要numeric_limits
的部分特化,如清单 54-5 所示。
namespace std {
template<class T>
class numeric_limits<rational<T>>
{
public:
static constexpr bool is_specialized{true};
static constexpr rational<T> min() noexcept {
return rational<T>(numeric_limits<T>::min());
}
static constexpr rational<T> max() noexcept {
return rational<T>(numeric_limits<T>::max());
}
static rational<T> lowest() noexcept { return -max(); }
static constexpr int digits{ 2 * numeric_limits<T>::digits };
static constexpr int digits10{ numeric_limits<T>::digits10 };
static constexpr int max_digits10{ numeric_limits<T>::max_digits10 };
static constexpr bool is_signed{ numeric_limits<T>::is_signed };
static constexpr bool is_integer{ false };
static constexpr bool is_exact{ true };
static constexpr int radix{ 2 };
static constexpr bool is_bounded{ numeric_limits<T>::is_bounded };
static constexpr bool is_modulo{ false };
static constexpr bool traps{ std::numeric_limits<T>::traps };
... omitted for brevity
};
} // namespace std
Listing 54-5.Partially Specializing numeric_limits for rational
部分特化的函数模板
不能部分特化函数模板。如前所述,允许完全特化,但不允许部分特化。不好意思。使用重载来代替,这通常比模板特化要好。
值模板参数
在我展示下一个部分特化的例子之前,我想介绍一个新的模板特性。模板通常使用类型作为参数,但也可以使用值。使用类型和可选名称声明值模板参数,与声明函数参数的方式非常相似。值模板参数仅限于可以指定编译时常量的类型:bool
、char
、int
等等,但是不允许使用字符串和大多数类。
例如,假设您想要修改您为 Exploration 50 编写的fixed
类,以便开发人员可以指定小数点后的位数。同时,您还可以使用模板参数来指定底层类型,如清单 54-6 所示。
template<class T, int N>
class fixed
{
public:
using value_type = T;
static constexpr int places{N};
static constexpr int places10{ipower(10, N)};
fixed();
fixed(T const& integer, T const& fraction);
fixed& operator=(fixed const& rhs);
fixed& operator+=(fixed const& rhs);
fixed& operator*=(fixed const& rhs);
... and so on...
private:
T value_; // scaled to N decimal places
};
template<class T, int N>
fixed<T, N>::fixed(value_type const& integer, value_type const& fraction)
: value_(integer * places10 + fraction)
{}
template<class T1, int N1, class T2, int N2>
bool operator==(fixed<T1,N1> const& a, fixed<T2,N2> const& b);
... and so on...
Listing 54-6.Changing fixed from a Class to a Class Template
将fixed
类转换成类模板的关键挑战是根据places
定义places10
。C++ 没有求幂运算符,但是你可以写一个constexpr
函数来计算一个整数的幂。编译时ipower
函数见清单 54-7 。
/// Compute base to the exp-th power at compile time.
template<class Base, class Exp>
Base constexpr ipower(Base base, Exp exp)
{
if (exp < Exp{})
throw std::domain_error("No negative powers of 10");
if (exp == Exp{})
{
if (base == Base{})
throw std::domain_error("0 to 0th power is not allowed");
return Base{1};
}
Base power{base};
for (Exp e{1}; e != exp;)
{
// invariant(power == base ** e)
if (e + e < exp)
{
power *= power;
e += e;
}
else
{
power *= base;
++e;
}
}
return power;
}
Listing 54-7.Computing a Power of 10 at Compile Time
假设您有一个实例化fixed<long, 0>
的应用程序。这种退化的情况与普通的long
没有什么不同,但是管理隐式小数点的开销和复杂性增加了。进一步假设您的应用程序的性能测量揭示了这种开销对应用程序的整体性能有可测量的影响。因此,您决定对fixed<T, 0>
的情况使用部分特化。使用部分特化,以便模板仍然接受基础类型的模板参数。
您可能想知道为什么应用程序程序员不简单地用普通的long
替换fixed<long, 0>
。在某些情况下,这是正确的解决方案。然而,在其他时候,fixed<long, 0>
的使用可能会隐藏在另一个模板中。因此,问题变成了特化哪个模板。为了这次探索,我们专做fixed
。
请记住,任何特化都必须提供完整的实现。你不需要把免费的函数特殊化。通过特化fixed
类模板,我们得到了我们需要的性能提升。清单 54-8 显示了fixed
的部分特化。
template<class T>
class fixed<T, 0>
{
public:
using value_type = T;
static constexpr T places{0};
static constexpr T places10{1};
fixed() : value_{} {}
fixed(T const& integer, T const&);
fixed& operator=(fixed const& rhs) { value_ = rhs; }
fixed& operator+=(fixed const& rhs) { value_ += rhs; }
fixed& operator*=(fixed const& rhs) { value_ *= rhs; }
... and so on...
private:
T value_; // no need for scaling
};
template<class T>
fixed<T, 0>::fixed(value_type const& integer, value_type const&)
: value_(integer)
{}
Listing 54-8.Specializing fixed for N == 0
如果rational
或fixed
的模板参数不是整数怎么办?如果用户不小心使用了std::string
怎么办?当然,灾难会接踵而至,用户会受到错误消息的轰炸。隐藏在这些消息深处的是真正的原因,但是用户发现这个问题有多容易呢?C++ 20 为模板的作者提供了一种简单的方法来指定模板参数的要求,这是下一篇文章的主题。
五十五、模板约束
模板的一个缺点是它们很容易被误用,意外地使用错误的类型作为模板参数会使编译器感到困惑,以至于它发出的错误消息需要 C++ 的高级学位才能破译。不过不用担心,因为模板作者可以对模板参数指定约束。这个探索描述了如何在你的模板上写约束。
约束函数模板
考虑一下如果你要传递一个字符串给ipower()
函数(清单 54-7 )或者一个浮点值会发生什么。该函数只对整型参数有效,但是因为 C++ 有几种不同的整型类型,所以编写一个模板比多次编写同一个函数更有意义,每次编写一个整型类型。我们真正想要的是一种将模板参数限制为整型的方法。清单 55-1 展示了如何将参数限制为整型。
/// Compute base to the exp-th power at compile time.
template<class Base, class Exp>
Base constexpr ipower(Base base, Exp exp)
requires std::integral<Base> and std::integral<Exp>
{
if (exp < Exp{})
throw std::domain_error("No negative powers of 10");
if (exp == Exp{})
{
if (base == Base{})
throw std::domain_error("0 to 0th power is not allowed");
return Base{1};
}
Base power{base};
for (Exp e{1}; e != exp;)
{
// invariant(power == base ** e)
if (e + e < exp)
{
power *= power;
e += e;
}
else
{
power *= base;
++e;
}
}
return power;
}
Listing 55-1.Requiring Template Argument Types to Be Integral
现在,如果您试图传递一个字符串或浮点值,编译器会告诉您已经违反了integral
约束。尝试用不同的参数类型调用 ipower
(),看看你的编译器会发出什么样的消息。
requires
修饰符跟在函数模板声明或定义中的函数头之后。requires
后面的内容看起来像一个布尔表达式,但略有不同。约束可以与逻辑操作符(and
、or
、not
)结合,并且,像布尔表达式一样,编译器用短路来计算约束。如果and
的左侧约束为假,则约束失败,不评估右侧约束。如果or
的左侧约束为真,则约束通过,而不评估右侧约束。对于复杂的约束,可以使用括号。
您还可以使用约束来区分重载函数。例如,std::vector<>
模板有几个名为insert
的函数,用于将一个或多个值插入向量。一个insert
函数是一个成员函数模板,它以两个迭代器作为参数,将一系列值复制到向量的特定位置:
template<class InputIterator>
iterator insert(const_iterator pos, InputIterator first, InputIterator last);
还有一个函数可以插入单个值的多个副本:
iterator insert(const_iterator pos, size_type count, T const& value);
编译器如何解释下面的代码?
std::vector<int> v;
v.insert(v.end(), 10, 20);
因为10
和20
的类型是int
,所以在InputIterator
类型设置为int
的情况下调用模板函数。显然,10 和 20 不是迭代器,编译器最终会发出许多错误。因此函数的迭代器形式被约束如下:
template<class InputIterator>
iterator insert(const_iterator pos, InputIterator first, InputIterator last)
requires std::input_iterator<InputIterator>;
<iterator>
头定义了std::input_iterator
。
现在轮到你了。修改清单51-5中的 copy()
函数,对模板参数添加合适的约束。<iterator>
头提供了std::output_iterator<I, T>
,其中I
是要测试的迭代器,T
是值类型。<ranges>
头提供了std::ranges::input_range<R>
和std::ranges::range_value_t<R>
,后者产生了范围R
的值类型。将您的函数与清单 55-2 进行比较。
template<class Input, class Output>
Output copy(Input input, Output output)
requires
std::ranges::input_range<Input> and
std::output_iterator<Output, std::ranges::range_value_t<Input>>
{
for (auto const& item : input)
*output++ = item;
return output;
}
Listing 55-2.Constraining the copy Function’s Arguments
指定约束的另一种方式是指定需要对函数参数执行的操作。例如,假设您想要实现一个操作符来将一个rational<T>
值乘以任何数值标量值,并且您想要允许用户定义的类型(std::integral<T>
和std::floating_point<T>
只适用于内置类型)。清单 55-3 展示了如何根据乘法和除法运算来定义约束。
template<class T, class U>
U operator*(rational<T> const& lhs, U const& rhs)
requires
requires(T lhs, U rhs) {
(lhs * rhs) / lhs;
}
{
return lhs.numerator() * rhs / lhs.denominator();
}
Listing 55-3.Constraining a Multiplication Operator
第二个requires
关键字开始一个requires
表达式。这个requires
表达式后面是看起来像函数的参数。花括号中是一系列需求,每个需求都以分号结束。在清单 55-3 中,需求只是一个表达式。如果表达式有效,则要求为真。比方说,如果用户试图将一个字符串传递给*
操作符,编译器会报告违反了(lhs * rhs) / lhs
约束。
列表中可能出现的另一种需求是类型需求,它只是类型的名称,比如成员类型名称或模板特化。如果类型有效,则要求为真。例如,所有标准容器都有一个名为size_type
的成员类型。如果你想写一个size()
函数来检查一个size_type
成员和一个size()
成员函数,你可以如清单 55-4 所示来写。
template<class T>
auto size(T const& container)
requires
requires(T container) {
container.size();
typename T::size_type;
{ container.size() } -> std::same_as<typename T::size_type>;
}
{
return container.size();
}
Listing 55-4.Constraining the size Function
container.size()
约束检查表达式是否有效,这意味着size()
成员函数是有效的。如果它是有效的,也就是说,编译器知道如何调用size()
成员函数,编译器检查第二个需求,或typename T::size_type
,它检查模板参数是否有一个size_type
类型成员。如果第二个要求为真,编译器检查第三个要求。这将使用标准的std::same_as
概念检查container.size()
是否有效以及返回类型是否为T::size_type
。最后一个需求包含了前两个,但是清单 55-4 展示了所有三个需求,只是为了展示需求表达的三种风格。
另一种语法是模板约束紧跟在模板头之后。清单 55-5 显示了与清单 55-4 相同的约束,但是语法不同。
template<class T>
requires
requires(T container) {
container.size();
typename T::size_type;
{ container.size() } -> std::same_as<typename T::size_type>;
}
auto size(T const& container)
{
return container.size();
}
Listing 55-5.Constraining the size Function
约束类模板
您还可以对类模板应用约束。例如,rational
模板要求其模板参数是整数类型:
template<class T>
requires std::integral<T>
class rational;
约束与函数模板的约束相同。在类定义中,还可以将约束应用于作为模板的单个成员函数。
为了进一步简化模板头,不使用class
来引入参数名,您可以使用一个概念,例如:
template<std::integral T>
class rational;
标准概念
如您所见,C++ 标准库提供了许多有用的约束测试。这些测试被称为概念。许多基本概念在<concepts>
标题中定义,附加概念在<iterator>
和<ranges>
中定义。在<concepts>
标题中定义的概念如下:
std::equality_comparable<T>
如果可以比较类型T
的值是否相等,则产生真约束。用==
运算符。如果调用者不提供谓词,find()
算法要求元素为equality_comparable
。
std::floating_point<T>
如果T
是内置浮点类型(float
、double
或long double
)之一,则产生真约束。
std::integral<T>
如果T
是内置整数类型之一(char
、short
、int
、long
或long long
,则产生一个真约束。
predicate<T>
如果T
是谓词,即返回布尔结果的函数,则产生真约束。许多算法,比如copy_if()
,需要一个谓词参数。
std::strict_weak_order<T>
如果类型为T
的值可以用<
运算符进行比较,则产生一个真正的约束,并且结果是一个严格的弱排序。这是在map
中使用T
作为键类型的要求。术语严格意味着一个表达式x < x
总是假的,而弱排序本质上是这样说的能力。
迭代器概念
<iterator>
头为每个迭代器类别定义了一个概念,加上一些更细粒度的概念。
std::bidirection_iterator<I>
如果I
是双向、随机访问或连续的,则产生真约束。
std::contiguous_iterator<I>
如果I
是连续的,则产生真约束。
std::forward_iterator<I>
如果I
是正向、双向、随机访问或连续,则产生真约束。
std::indirectly_readable<I>
如果I
是任何读迭代器,则产生一个真约束,也就是说,可以间接或通过解引用操作符(*
)读取一个值。
std::indirectly_writable<I>
如果I
是任何写迭代器,则产生一个真约束,也就是说,可以间接或通过解引用操作符(*
)写一个值。
std::input_iterator<I>
如果I
是输入、正向、双向、随机访问或连续,则产生真约束。
std::input_or_output_iterator<I>
如果I
是输入或输出迭代器,则产生真约束。这两种迭代器类型有一个共同的特点,那就是它们是可递增的,并且代码必须在迭代之间解引用迭代器一次。
std::output_iterator<I>
如果I
是输出、正向、双向、随机访问或连续,则产生真约束。
std::permutable<I>
如果I
可用于对可迭代范围内的数据进行重新排序,则产生真约束。置换算法可以移动或交换数据。
std::random_access_iterator<I>
如果I
是随机存取或连续的,则产生真约束。
std::sortable<I>
如果I
可用于对可迭代范围内的数据进行排序,则产生真约束。排序算法可以移动或交换数据,并且必须能够以严格的弱排序比较元素。
范围概念
<range>
头为每个迭代器类别定义了一个概念,加上一些更细粒度的概念。
std::ranges::bidirectional_range<R>
如果R
是双向、随机访问或连续范围,如链表、数组或向量,则产生真约束。
std::ranges::contiguous_range<R>
如果R
是一个连续的范围,如数组或向量,则产生一个真约束。
std::ranges::forward_range<R>
如果R
是正向、双向、随机访问或连续范围,如输入视图或标准容器,则产生真约束。
std::ranges::input_range<R>
如果R
是输入、前向、双向、随机访问或连续范围,如输入视图,则产生真约束。
std::ranges::output_range<R>
如果R
是输出、正向、双向、随机访问或连续范围,则产生真约束。到目前为止,在本书中,我们使用了输出迭代器,而不是输出范围。输出范围的一个例子是已经预先调整大小以适应预期输出的向量。
std::ranges::random_access_range<R>
如果R
是随机访问或连续范围,如数组或向量,则产生真约束。
std::ranges::range<R>
如果R
是任何范围,比如一对迭代器、一个视图或一个标准容器,则产生一个真约束。
std::ranges::sized_range<R>
如果R
是一个大小已知的范围,并且该大小可以在常量时间内确定(不是通过迭代该范围),则产生一个真约束。
std::ranges::view<R>
如果R
是一个视图,则产生一个真正的约束。作为一个视图,一个范围必须是轻量级的,也就是说,在固定的时间内是可移动和可销毁的。在恒定时间内可销毁意味着视图不能拥有该范围内的任何元素,因为销毁该范围需要销毁该范围内的对象。
写出你自己的概念
约束可以是与模板参数相关的布尔表达式。最常见的情况是,这个表达式使用一种特殊的模板来编写模板约束,称为概念。例如,假设您想要一个针对任何整数类型的约束,包括用户定义的类型。要求是,如果用户定义了一个整数类型,std::numeric_limits
模板必须专用于该类型:
template<class T>
concept any_integral = std::numeric_limits<T>::is_integer;
让我们看看在编写面向范围的类时概念的应用。join
视图获取一系列范围并将它们展平成一个范围。这种方法的一个实际应用是将一系列字符串连接成一个字符串。但是它并没有完全完成工作。最终结果是一个范围内的视图,可以用来构造一个新的字符串,但这通常需要将连接的视图保存到一个变量中,然后使用变量的begin()
和end()
来构造一个字符串。清单 55-6 显示了一个类,它可以在视图管道的末端为我们创建std::string
对象。
清单 55-6。 定义 store
函数模板
import <algorithm>;
import <concepts>;
import <iostream>;
import <ranges>;
import <string>;
import <vector>;
template<class Range>
concept can_reserve =
std::ranges::sized_range<Range> and
requires(Range r) {
r.reserve(0);
};
template<class Container>
concept can_insert_back =
requires(Container c) {
std::back_inserter(c);
};
template<can_insert_back Container>
class store_t
{
public:
using container_type = Container;
using value_type = std::ranges::range_value_t<container_type>;
store_t(container_type& output) : output_{output} {}
template<can_reserve Range>
Container& operator()(Range const& input) const {
output_.reserve(std::ranges::size(output_)+std::ranges::size(input));
std::ranges::copy(input, std::back_inserter(output_));
return output_;
}
template<class Range>
requires (not can_reserve<Range>)
Container& operator()(Range const& input) const {
std::ranges::copy(input, std::back_inserter(output_));
return output_;
}
private:
container_type& output_;
};
template<class T>
store_t<T> store(T& container) { return store_t<T>(container); }
template<class In, class Out>
Out& operator|(In range, store_t<Out> const& storer)
{
return storer(std::forward<In>(range));
}
int main() {
std::vector<std::string> strings{ "this" " is ", "a", " test", ".\n" };
std::string str;
std::ranges::views::join(strings) | store(str);
std::cout << str;
}
尽管can_insert_back
概念只有一种用途,定义一个单独的概念而不是使用一个本地模板约束有两个好处:
-
通过给约束命名,它为人类读者和维护者提供了一些文档。
-
一个单独的约束意味着类声明不那么杂乱,这使得它稍微容易阅读。
这些优点都是给人类读者的。编译器不在乎。can_reserve
概念类似。它减少了函数调用操作符周围的混乱,因此更容易看出一个操作符适用于可以为副本预分配内存的情况(比如,为一个向量),另一个适用于输出范围将一次扩展一个元素的情况(比如,为一个链表)。
模板约束和概念是对 C++ 20 的一个很好的补充,你应该期待该语言的未来版本通过标准库的其余部分扩展约束的使用。第三方库也将开始采用约束,这将使它们更容易使用。
下一篇文章介绍了一个语言特性,它可以帮助您管理自定义类型:名称空间。
五十六、名称和命名空间
几乎标准库中的每个名称都以std::
开头,只有标准库中的名称才允许以std::
开头。对于您自己的名字,您可以定义其他前缀,这是一个好主意,也是避免名字冲突的极好方法。库和大型程序尤其受益于正确的分区和命名。然而,模板和名称有些复杂,这种探索有助于澄清问题。
命名空间
名称std
是一个名称空间的例子,这是一个命名作用域的 C++ 术语。名称空间是一种组织名称的方式。当您看到以std::
开头的名称时,您知道它在标准库中。好的第三方库使用名称空间。例如,开源 Boost 项目( www.boost.org
)使用boost
名称空间来确保名称(如boost::container::vector
)不会干扰标准库中的类似名称,如std::vector
。应用程序也可以利用名称空间。例如,不同的项目团队可以将他们自己的名字放在不同的名称空间中,因此一个团队的成员可以自由地命名函数和类,而不需要与其他团队进行核对。例如,GUI 团队可能使用名称空间gui
并定义一个gui::tree
类,它管理用户界面中的一个树小部件。数据库团队可能会使用db
名称空间。因此,db::tree
可能表示用于在磁盘上存储数据库索引的树形数据结构。数据库调试工具可以使用两个tree
类,因为db::tree
和gui::tree
之间没有冲突。名称空间将名称分开。
要创建命名空间并在其中声明名称,必须定义命名空间。名称空间定义以关键字namespace
开始,后面跟着一个可选的标识符来命名名称空间。接下来是花括号内的声明。与类定义不同,命名空间定义不以右大括号后的分号结束。花括号中的所有声明都在名称空间的范围内。您必须在任何函数之外定义一个命名空间。清单 56-1 定义了名称空间numeric
,并在其中定义了rational
类模板。
namespace numeric
{
template<class T>
class rational
{
... you know what goes here...
};
template<class T>
bool operator==(rational<T> const& a, rational<T> const& b);
template<class T>
rational<T> operator+(rational<T> const& a, rational<T> const& b);
... and so on...
} // namespace numeric
Listing 56-1.Defining the rational Class Template in the numeric Namespace
命名空间定义可以是不连续的。这意味着您可以拥有许多独立的命名空间块,它们都属于同一个命名空间。因此,多个模块可以各自定义同一个名称空间,并且每个定义都向同一个公共名称空间添加名称。在模块接口中,您可以导出整个名称空间或仅导出名称空间中的某些名称。清单 56-2 展示了如何在同一个numeric
名称空间中定义fixed
类模板,即使是在不同的模块中(比如说,fixed
)。
export module fixed;
namespace numeric
{
export template<class T, int N>
class fixed
{
... copied from Exploration 54...
};
export template<class T, int N>
bool operator==(fixed<T,N> const& a, fixed<T,N> const& b);
export template<class T, int N>
fixed<T,N> operator+(fixed<T,N> const& a, fixed<T,N> const& b);
// and so on...
} // namespace numeric
Listing 56-2.Defining the fixed Class Template in the numeric Namespace
即使没有显式导出numeric
名称空间,导入fixed
模块的模块也会导入numeric::fixed
。因为名称空间没有被导出,所以如果您希望能够从其他模块调用它,每个自由函数都需要一个export
声明。与fixed
相关的自由函数和运算符必须在numeric
名称空间中定义。我将在后面的探索中解释为什么,但我现在想指出来,因为它非常重要。
当您在名称空间中声明但未定义实体(如函数)时,您可以选择如何定义该实体,如下所述:
-
使用相同或另一个命名空间定义,并在命名空间定义中定义实体。
-
在名称空间之外定义实体,并在实体名称前加上名称空间名称和范围运算符(
::
)。
清单 56-3 展示了两种定义风格。(声明在清单 56-1 和 56-2 中。)
namespace numeric
{
template<class T>
rational<T> operator+(rational<T> const& a, rational<T> const& b)
{
rational<T> result{a};
result += b;
return result;
}
}
template<class T, int N>
numeric::fixed<T, N> numeric::operator+(fixed<T, N> const& a, fixed<T, N> const& b)
{
fixed<T, N> result{a};
result += b;
return result;
}
Listing 56-3.Defining Entities in a Namespace
第一种形式很简单。一如既往,定义必须遵循声明。这是你最常看到的形式。
当定义很少时,可以使用第二种形式。编译器看到名称空间名称(numeric
),后面是作用域操作符,并且知道在该名称空间中查找后续名称(operator*
)。编译器认为函数的其余部分在命名空间范围内,因此您不必在声明的其余部分(即函数参数和函数体)指定命名空间名称。函数的返回类型在函数名之前,这使它位于名称空间范围之外,所以您仍然必须使用名称空间名称。为了避免歧义,不允许在一个名称空间中有一个名称空间和一个同名的类。
我在 Exploration 24 中提到的另一种编写函数返回类型的方式让你不用重复命名空间范围就可以编写返回类型,因为函数名为你建立了范围。不要以返回类型开始函数头,而是使用auto
关键字,并将返回类型放在函数参数之后,用->
表示返回类型,如清单 56-4 所示。
template<class T, int N>
auto numeric::operator+(fixed<T, N> const& a, fixed<T, N> const& b) -> fixed<T, N>
{
fixed<T, N> result{a};
result += b;
return result;
}
Listing 56-4.Alternative Style of Function Declaration
传统上,当您在模块中定义名称空间时,该模块包含一个名称空间定义,其中包含所有必需的声明和定义。当您在一个单独的源文件中实现函数和其他实体时,我发现编写一个显式的名称空间并在名称空间中定义函数是最方便的,但是有些程序员更喜欢省略名称空间定义。相反,它们在定义实体时使用名称空间名称和范围操作符。以名称空间名称和作用域操作符开头的实体名称就是一个由限定的名称的例子——也就是说,一个名称明确地告诉编译器在哪里可以找到该名称的声明。
名字rational<int>::value_type
是合格的,因为编译器知道在类模板rational
中查找value_type
,专门针对int
。名字std::vector
是一个限定名,因为编译器在名字空间std
中查找vector
。另一方面,编译器在哪里查找名字std
?在回答这个问题之前,我必须深入研究嵌套名称空间这个主题。
嵌套命名空间
命名空间可以嵌套,也就是说,您可以在另一个命名空间中定义一个命名空间,如下所示:
namespace exploring_cpp
{
namespace numeric {
template<class T> class rational
{
... and so on ...
};
}
}
为了使用嵌套的名称空间,限定符从最外层的名称空间开始按顺序列出所有的名称空间。用范围运算符(::
)分隔每个名称空间,例如:
exploring_cpp::numeric::rational<int> half{1, 2};
std::ranges::copy(source, destination);
顶级名称空间,如std
或exploring_cpp
,实际上是一个嵌套的名称空间。它的外部名称空间被称为全局名称空间。在任何函数之外声明的所有实体都在一个命名空间中——显式命名空间或全局命名空间。因此,函数之外的名字被称为在命名空间范围。短语全局范围是指在隐式全局名称空间中声明的名称,这意味着在任何显式名称空间之外。通过在名称前加上范围运算符来限定全局名称。
::exploring_cpp::numeric::rational<int> half{1, 2};
::std::ranges::copy(source, destination);
您阅读的大多数程序都不会使用显式的全局作用域运算符。相反,程序员倾向于依赖普通的 C++ 规则来查找名字,让编译器自己找到全局名字。到目前为止,你写的每个函数都是全局的;对这些函数的每次调用都是不合格的。编译器从来没有遇到过非限定名的问题。如果您遇到局部名称隐藏全局名称的情况,您可以显式引用全局名称。清单 56-5 展示了糟糕的名字选择可能带来的麻烦,以及如何使用合格的名字来摆脱困境。
1 import <cmath>;
2 import <numeric>;
3 import <vector>;
4
5 namespace stats {
6 // Really bad name for a functor to compute sum of squares,
7 // for use in determining standard deviation.
8 class std
9 {
10 public:
11 std(double mean) : mean_{mean} {}
12 double operator()(double acc, double x)
13 const
14 {
15 return acc + square(x - mean_);
16 }
17 double square(double x) const { return x * x; }
18 private:
19 double mean_;
20 };
21
22 // Really bad name for a function in the stats namespace.
23 // It computes standard deviation.
24 double stats(::std::vector<double> const& data)
25 {
26 double std{0.0}; // Really, really bad name for a local variable
27 if (not data.empty())
28 {
29 double sum{::std::accumulate(data.begin(), data.end(), 0.0)};
30 double mean{sum / data.size()};
31 double sumsq{::std::accumulate(data.begin(), data.end(), 0.0,
32 stats::std(mean))};
33 double variance{sumsq / data.size() - mean * mean};
34 std = ::std::sqrt(variance);
35 }
36 return std;
37 }
38 }
Listing 56-5.Coping with Conflicting Names
局部变量std
与同名的名称空间并不冲突,因为编译器知道只有类名和名称空间才能出现在作用域操作符的左侧。另一方面,类std
确实冲突,所以使用一个空的std::
限定符是不明确的。您必须使用::std
(对于标准库名称空间)或stats::std
(对于类)。对局部变量的引用必须使用普通的std
。
第 24 行的名称stats
命名了一个函数,所以它不会与名称空间stats
冲突。因此,在第 32 行使用stats::std
不会有歧义。
第 29 行和第 31 行调用的accumulate
算法,正如其名称所暗示的那样。它将一个范围内的所有元素添加到一个起始值,要么通过调用+
操作符,要么通过调用一个二元函子,该函子将该范围内的和值作为参数。
删除全局 范围运算符从 ::std::accumulate
(第 29 行和第 31 行)到 std::accumulate
。重新编译程序。你的编译器给你什么消息?
将文件恢复到原始形式。从 ::std::vector
中删除第一个 ::
限定词(第 24 行)。编译器给你什么信息?
将文件恢复到原始形式。从 stats::std
中去掉 stats::
限定词(第 32 行)。编译器给你什么信息?
理智的人不会故意在 C++ 程序中命名一个类std
,但我们都会犯错误。(也许你有一个在建筑 CAD 系统中表示建筑元素的类,你不小心省略了stud
中的字母u
。)通过查看编译器在遇到名称冲突时发出的各种消息,当您意外创建的名称与第三方库或项目中另一个团队发明的名称冲突时,您可以更好地识别这些错误。
大多数应用程序程序员不必使用全局范围前缀,因为您可以小心选择不冲突的名称。另一方面,库的作者永远不知道他们的代码将在哪里被使用,或者代码将使用什么名字。因此,谨慎的库作者经常使用全局范围前缀。
全局名称空间
在所有名称空间之外声明的名字是全局的。过去,我使用全局来表示“在任何函数之外”,但那是在你了解名称空间之前。C++ 程序员引用在命名空间范围声明的名字,这是我们所说的“在任何函数之外”这种名称可以在命名空间中声明,也可以在任何显式命名空间之外声明。
程序的main
函数必须是全局的,也就是说,在名称空间范围内,但不在任何名称空间内。如果你在一个名称空间中定义了另一个名为main
的函数,它不会干扰全局main
,但是它会让任何阅读你的程序的人感到困惑。
标准命名空间
如您所知,标准库使用std
名称空间。不允许在std
名称空间中定义任何名称,但是可以特化在std
中定义的模板,只要至少有一个模板参数是用户定义的类型。
C++ 标准库从 C 标准库继承了一些函数、类型和对象。您可以认出 C 派生的头文件,因为它们的名字以一个额外的字母c
开头;例如,<cmath>
是 C 头文件<math.h>
的 C++ 等价物。有些 C 名字,比如EOF
,不遵循命名空间规则。这些名字通常都是用大写字母写的,以警告你它们是特别的。你不必关心细节;请注意,不能对这些名称使用范围操作符,这些名称总是全局的。当你在语言参考中查找一个名字时,这些特殊的名字被称为宏。
C++ 标准在库实现如何继承 C 标准库方面提供了一定的灵活性。具体规则是一个形式为<header.h>
(对于一些 C header
,比如math
)的 C 头在全局命名空间中声明它的名字,实现决定这些名字是否也在std
命名空间中。形式为<cheader>
的 C 头文件在std
名称空间中声明了它的名字,实现也可以在全局名称空间中声明它们。无论您选择哪种风格,所有 C 标准函数都保留给实现,这意味着您不能在全局名称空间中自由使用任何 C 标准函数名。如果要使用相同的名称,必须在不同的名称空间中声明。有人喜欢<cstddef>
和std::size_t
,有人更喜欢<stddef.h>
和size_t
。选择一种风格并坚持下去。
我的建议是不要纠结于哪些名字来源于 C 标准库,哪些是 C++ 特有的。相反,标准库中的任何名字都是禁止使用的。唯一的例外是,当您为了相同的目的想要使用相同的名称,但是在您自己的名称空间中。例如,你可能想重载abs
函数来处理rational
或fixed
对象。在它们各自的名称空间中这样做,与所有重载操作符和其他自由函数放在一起。
Caution
许多 C++ 参考省略了标准库的 C 部分。但是,正如您所看到的,C 部分在名称冲突方面是最有问题的。因此,确保你的 C++ 参考是完整的,或者用完整的 C 18 库参考来补充不完整的 C++ 参考。
使用名称空间
为了使用任何名字,C++ 编译器必须能够找到它,这意味着要识别它被声明的范围。使用名称空间中的名称(如rational
或fixed
)的最直接的方法是使用限定名,即名称空间名称作为前缀,例如numeric
,后面跟着作用域操作符(::
)。
numeric::rational<long> pi_r{80143857L, 25510582L};
numeric::fixed<long, 6> pi_f{3, 141593};
当编译器看到名称空间名称和双冒号(::
)时,它知道在该名称空间中查找后续名称。不同名称空间中的相同实体名称不会发生冲突。
然而,有时候,您最终会大量使用名称空间,简洁成为一种美德。接下来的两节描述了几个选项。
命名空间别名
如果您有深度嵌套的名称空间或长名称空间名称,您可以使用自己的缩写或别名,例如
namespace rng = std::ranges;
rng::copy(source, destination);
请务必选择一个不会与其他名称冲突的别名。这种技术最好在一定范围内使用,以保持其效果尽可能有限,并避免意外。
using 指令
你以前见过一个using
指令,但是如果你需要复习,看看这个:
using namespace std;
语法如下:using
关键字、namespace
关键字和一个名称空间名称。一个using
指令指示编译器将命名空间中的所有名字视为全局名字。(精确的规则稍微复杂一些。但是,除非您有名称空间的嵌套层次结构,否则这种简化是准确的。)您可以列出多个using
指令,但是您冒着在名称空间中引入名称冲突的风险。一个using
指令只影响你放置它的范围。因为它会对名称查找产生很大的影响,所以尽可能将using
指令限制在最窄的范围内;通常这是一个内部块。
虽然using
指令有它的优点——我在本书中使用了它们——但是你必须小心。它们阻碍了名称空间的关键优势:避免名称冲突。不同名称空间中的名称通常不会冲突,但是如果您试图混合声明一个公共名称的名称空间,编译器将会报错。
如果您不小心使用了using
指令,您可能会意外地使用来自错误名称空间的名称。如果你幸运的话,编译器会告诉你你的错误,因为你的代码以违反语言规则的方式使用了错误的名称。如果你不够幸运,错误的名字会碰巧有相同的语法,直到很久很久以后你才会注意到你的错误。
不要在模块接口中放置using
指令。这破坏了每个导入您的模块的人的名称空间。尽可能将using
指令保持在本地,在尽可能小的范围内。
一般来说,我尽量避免使用using
指令。您应该习惯于阅读完全限定的名称。另一方面,有时长名字会妨碍对复杂代码的理解。我很少在同一个范围内使用一个以上的using
指令。到目前为止,我唯一一次这样做是当所有的名称空间都由同一个库定义时,所以我知道它们一起工作,我不会遇到命名问题。清单 56-6 展示了using
指令是如何工作的。
1 import <iostream>;
2
3 void print(int i)
4 {
5 std::cout << "int: " << i << '\n';
6 }
7
8 namespace labeled
9 {
10 void print(double d)
11 {
12 std::cout << "double: " << d << '\n';
13 }
14 }
15
16 namespace simple
17 {
18 void print(int i)
19 {
20 std::cout << i << '\n';
21 }
22 void print(double d)
23 {
24 std::cout << d << '\n';
25 }
26 }
27
28 void test_simple()
29 {
30 using namespace simple;
31 print(42); // ???
32 print(3.14159); // finds simple::print(double)
33 }
34
35 void test_labeled()
36 {
37 using namespace labeled;
38 print(42); // find ::print(int)
39 print(3.14159); // finds labeled::print(double)
40 }
41
42 int main()
43 {
44 test_simple();
45 test_labeled();
46 }
Listing 56-6.Examples of using Directives
如果尝试编译清单 56-6 会发生什么?
错误在第 31 行。using
指令有效地合并了simple
名称空间和全局名称空间。因此,现在你有两个名为print
的函数,它们接受一个int
参数,而编译器不知道你想要哪个。通过限定对print(42)
(第 32 行)的调用来解决这个问题,这样它就调用了simple
名称空间中的函数。你期望程序输出是什么?
试试看。确保你得到你想要的。第 31 行现在应该是这样的:
simple::print(42);
using 声明
比using
指令更具体,也更不危险的是using
声明。一个using
声明将一个名字从另一个名称空间导入到一个局部范围,如下所示:
using numeric::rational;
一个using
声明将名字添加到局部作用域,就像你已经显式声明了它一样。因此,在放置using
声明的范围内,您可以无条件地使用声明的名称(例如,rational
)。清单 56-7 展示了using
声明如何帮助避免您在清单 56-6 中使用指令时遇到的问题。
1 import <iostream>;
2
3 void print(int i)
4 {
5 std::cout << "int: " << i << '\n';
6 }
7
8 namespace labeled
9 {
10 void print(double d)
11 {
12 std::cout << "double: " << d << '\n';
13 }
14 }
15
16 namespace simple
17 {
18 void print(int i)
19 {
20 std::cout << i << '\n';
21 }
22 void print(double d)
23 {
24 std::cout << d << '\n';
25 }
26 }
27
28 void test_simple()
29 {
30 using simple::print;
31 print(42);
32 print(3.14159);
33 }
34
35 void test_labeled()
36 {
37 using labeled::print;
38 print(42);
39 print(3.14159);
40 }
41
42 int main()
43 {
44 test_simple();
45 test_labeled();
46 }
Listing 56-7Examples of using Declarations with Namespaces
预测程序的输出。
这一次,编译器可以找到simple::print(int)
,因为using
声明将名称注入局部范围。因此,本地名称不会与全局print(int)
函数冲突。另一方面,编译器不会为第 38 行调用::print(int)
。相反,它调用labeled::print(double)
,将42
转换为42.0
。
你对编译器的行为感到困惑吗?我来解释一下。当编译器试图解析重载的函数或运算符名称时,它会查找第一个声明匹配名称的范围。然后,它从该范围收集所有重载的名称,并且只从该范围收集。最后,它通过选择最佳匹配来解析名称(或者,如果找不到准确的匹配,则报告一个错误)。一旦编译器找到匹配,它就停止在其他范围或外部命名空间中查找。
在这种情况下,编译器会看到对print(42)
的调用,并首先在局部范围内查找名称print
,在这里它会找到一个从labeled
名称空间导入的名为print
的函数。所以它停止寻找名称空间,并试图解析名称print
。它找到一个函数,该函数带有一个double
参数。编译器知道如何将一个int
转换成一个double
,所以它认为这个函数匹配并调用它。编译器甚至从不查看全局名称空间。
你如何指示编译器也考虑全局 print
函数?
为全局print
函数添加一个using
声明。在第 37 行和第 38 行之间,插入以下内容:
using ::print;
当编译器试图解析print(int)
时,它会找到labeled::print(double)
和::print(int)
,两者都被导入到局部范围内。然后,它通过考虑这两个函数来解决重载。print(int)
函数是int
参数的最佳匹配。
现在在同一位置添加using simple::print;
。现在编译这个例子,你预计会发生什么?
现在编译器有太多的选择——它们会发生冲突。一个using
指令不会引起这种冲突,因为它只是改变了编译器查找名字的命名空间。然而,using
声明向局部范围添加了一个声明。如果你添加了太多的声明,这些声明会冲突,编译器会抱怨。
当一个using
声明命名一个模板时,该模板名被带入局部范围。编译器跟踪模板的全部和部分特化。using
声明只影响编译器是否找到模板。一旦找到模板并决定实例化它,编译器就会找到合适的特化。这就是为什么您可以特化一个在标准库中定义的模板——也就是说,在std
名称空间中。
一个using
指令和一个using
声明的关键区别在于一个using
指令不影响局部范围。然而,using
声明将非限定名引入了局部范围。这意味着你不能在同一个范围内声明你自己的名字。清单 56-8 说明了区别。
import <iostream>;
void demonstrate_using_directive()
{
using namespace std;
typedef int ostream;
ostream x{0};
std::cout << x << '\n';
}
void demonstrate_using_declaration()
{
using std::ostream;
typedef int ostream;
ostream x{0};
std::cout << x << '\n';
}
Listing 56-8.Comparing a using Directive with a using Declaration
ostream
的本地声明会干扰using
声明,但不会干扰using
指令。一个局部作用域只能有一个具有特定名称的对象或类型,一个using
声明会将该名称添加到局部作用域中,而一个using
指令则不会。
类中的using
声明
一个using
声明也可以导入一个类的成员。这不同于名称空间using
声明,因为您不能将任何旧成员导入任何旧类,但是您可以将名称从基类导入派生类。有几个原因可以解释你为什么想这么做。两个直接原因是
-
基类声明了一个函数,派生类声明了一个同名的函数,而您希望重载找到这两个函数。编译器只在单个类范围内查找重载。使用
using
声明将基类函数导入派生类范围,重载可以在派生类范围中找到两个函数,从而选择最佳匹配。 -
当私有继承时,可以通过在派生类的公共部分放置一个
using
声明来选择性地公开成员。
清单 56-9 展示了using
声明。随着您学习更高级的 C++ 技术,您将了解到using
声明的更多优点。
import <iostream>;
class base
{
public:
void print(int i) { std::cout << "base: " << i << '\n'; }
};
class derived1 : public base
{
public:
void print(double d) { std::cout << "derived: " << d << '\n'; }
};
class derived2 : public base
{
public:
using base::print;
void print(double d) { std::cout << "derived: " << d << '\n'; }
};
int main()
{
derived1 d1{};
derived2 d2{};
d1.print(42);
d2.print(42);
}
Listing 56-9.Examples of using Declarations with Classes
预测程序的输出。
类derived1
有一个名为print
的成员函数。调用d1.print(42)
将42
转换为42.0
并调用那个函数。类derived2
从基类导入print
。因此,重载决定了d2.print(42)
的最佳匹配,并在基类中调用print
。输出如下所示:
derived: 42
base: 42
名称查找
在没有名称空间的情况下,查找函数或操作符名称很简单。编译器首先在本地块中查找,然后在外部块和外部命名空间之前的内部命名空间中查找,直到最后,编译器搜索全局声明。它在包含匹配声明的第一个块中停止搜索。如果编译器正在寻找一个函数或操作符,那么这个名字可能会被重载,所以编译器会考虑在同一个作用域中声明的所有匹配的名字,而不考虑参数。
查找成员函数略有不同。如前所述,当编译器在类上下文中查找非限定名时,它首先在本地块和封闭块中进行搜索。对于所有的祖先类,搜索继续考虑类的成员,然后是它的基类,等等。同样,当查找重载名称时,编译器会考虑它在同一范围内找到的所有匹配名称,即同一类或块。
名称空间使名称查找规则变得复杂。假设您想要使用在exploring_cpp::numeric
名称空间中定义的rational
类型。您知道如何为类型使用限定名,但是如何处理加法或 I/O 操作符,例如下面的操作符:
exploring_cpp::numeric::rational<int> r;
std::cout << r + 1 << '\n';
加法运算符的全称是exploring_cpp::numeric::operator+
。但是通常,您使用加法运算符,而不指定名称空间。因此,编译器需要一些帮助来确定哪个命名空间包含操作符声明。诀窍是编译器检查操作数的类型,并在包含这些类型的名称空间中寻找重载运算符。这就是所谓的参数相关查找(ADL)。
编译器收集几组要搜索的范围。它首先使用普通的查找规则确定要搜索的范围,如本节开头所述。对于每个函数参数或运算符操作数,编译器还基于参数类型收集一组命名空间。如果类型是类类型,编译器选择包含类声明的命名空间和包含其所有祖先类的命名空间。如果类型是类模板的专用化,编译器将选择包含主模板的命名空间和所有模板参数的命名空间。编译器形成所有这些作用域的并集,然后在它们中搜索函数或运算符。如你所见,ADL 的目标是包容性的。编译器努力发现哪个作用域声明了操作符或函数名。
为了更好地理解 ADL 的重要性,请看一下清单 56-10 。
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <vector>;
namespace parser
{
class token
{
public:
token() : text_{} {}
token(std::string& s) : text_{s} {}
token& operator=(std::string const& s) { text_ = s; return *this; }
std::string text() const { return text_; }
private:
std::string text_;
};
}
std::istream& operator>>(std::istream& in, parser::token& tok)
{
std::string str{};
if (in >> str)
tok = str;
return in;
}
std::ostream& operator<<(std::ostream& out, parser::token const& tok)
{
out << tok.text();
return out;
}
int main()
{
using namespace parser;
using namespace std;
vector<token> tokens{};
ranges::copy(ranges::istream_view<token>(std::cin), back_inserter(tokens));
ranges::copy(tokens, ostream_iterator<token>(cout, "\n"));
}
Listing 56-10.Reading and Writing Tokens
当你编译程序时会发生什么?
一些编译器试图提供帮助,用消息填充你的控制台。问题的核心是istream_iterator
和ostream_iterator
调用标准的输入(>>
)和输出(<<
)操作符。在清单 52-10 的情况下,编译器通过普通的查找将操作符定位为istream
和ostream
类的成员函数。标准库为内置类型声明了这些成员函数操作符,因此编译器无法找到类型为parser::token
的参数的匹配项。因为编译器在一个类范围内找到了匹配,所以它从来没有搜索过全局范围,所以它从来没有找到定制的 I/O 操作符。
编译器应用 ADL 并搜索parser
名称空间,因为<<
和>>
的第二个操作数的类型为parser::token
。它搜索std
名称空间,因为第一个操作数具有类型std::istream
或std::ostream
。它在这些命名空间中找不到 I/O 操作符的匹配项,因为这些操作符在全局范围内。
现在您明白了为什么在与主类型相同的名称空间中声明所有关联的操作符是至关重要的。如果不这样做,编译器就找不到它们。将 I/O 操作符移动到 parser
名称空间,看到程序现在工作了。将你的程序与清单 56-11 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <string>;
import <string_view>;
import <vector>;
namespace parser
{
class token
{
public:
token() : text_{} {}
token(std::string_view s) : text_{s} {}
token& operator=(std::string_view s) { text_ = s; return *this; }
std::string text() const { return text_; }
private:
std::string text_;
};
std::istream& operator>>(std::istream& in, parser::token& tok)
{
std::string str{};
if (in >> str)
tok = str;
return in;
}
std::ostream& operator<<(std::ostream& out, parser::token const& tok)
{
out << tok.text();
return out;
}
}
int main()
{
using namespace parser;
using namespace std;
vector<token> tokens{};
ranges::copy(ranges::istream_view<token>(std::cin), back_inserter(tokens));
ranges::copy(tokens, ostream_iterator<token>(cout, "\n"));
}
Listing 56-11.Move the I/O Operators into the parser Namespace
要查看编译器如何扩展其 ADL 搜索,修改程序,将容器从 vector
更改为 map
,并统计每个标记的出现次数。(还记得探索 23 ?)因为一个map
存储了pair
对象,所以编写一个输出操作符来打印成对的令牌和计数。这意味着ostream_iterator
用来自名称空间std
的两个参数调用<<
操作符。尽管如此,编译器还是找到了您的操作符(在parser
名称空间中),因为std::pair
的模板参数在parser
中。你的程序可能最终看起来类似于清单 56-12 。
import <algorithm>;
import <iostream>;
import <iterator>;
import <map>;
import <string>;
import <string_view>;
namespace parser
{
class token
{
public:
token() : text_{} {}
token(std::string_view s) : text_{s} {}
token& operator=(std::string_view s) { text_ = s; return *this; }
std::string text() const { return text_; }
private:
std::string text_;
};
// To store tokens in a map.
bool operator<(token const& a, token const& b)
{
return a.text() < b.text();
}
std::istream& operator>>(std::istream& in, parser::token& tok)
{
std::string str{};
if (in >> str)
tok = str;
return in;
}
std::ostream& operator<<(std::ostream& out, parser::token const& tok)
{
out << tok.text();
return out;
}
std::ostream& operator<<(std::ostream& out,
std::pair<const token, long> const& count)
{
out << count.first.text() << '\t' << count.second << '\n';
return out;
}
}
int main()
{
using namespace parser;
using namespace std;
map<token, long> tokens{};
token tok{};
while (cin >> tok)
++tokens[tok];
ranges::copy(tokens,
ostream_iterator<pair<const token, long>>(cout));
}
Listing 56-12.Counting Occurrences of Tokens
现在您已经了解了模板和名称空间,是时候看看它们的一些实际用途了。接下来的几个探索将从标准容器开始,更仔细地研究标准库的各个部分。
五十七、容器
到目前为止,您使用的唯一标准容器是vector
和map
。我在探索 9 和探索 46 中提到过array
但从未深入。这个探索介绍了剩余的容器,并讨论了容器的一般性质。当第三方库实现附加容器时,它们通常遵循标准库设置的模式,并使它们的容器遵循相同的要求。
容器的属性
容器类型实现了熟悉的数据结构,比如树、列表、数组等等。它们都有一个共同的目的,即在一个容器对象中存储一组相似的对象。您可以将容器视为单个实体:比较、复制、分配等等。您还可以访问容器中的单个项目。一种容器类型与另一种容器类型的区别在于容器在其中存储项目的方式,这反过来会影响访问和修改容器中项目的速度。
标准容器分为两大类:顺序容器和关联容器。不同之处在于,您可以控制序列容器中的项目顺序,但不能控制关联容器中的项目顺序。因此,关联容器为访问和修改其内容提供了改进的性能。标准的序列容器有array
(固定大小)deque
(双端队列)forward_list
(单链表)list
(双链表)vector
(变长数组)。forward_list
类型的工作方式不同于其他容器(由于单链表的性质),它是专门用于特殊用途的。本书不涉及forward_list
,但你可以在任何 C++ 参考资料中找到。
关联容器有两个子类别:有序的和无序的。有序容器按照数据相关的顺序存储键,这是由<
操作符或调用者提供的仿函数给出的。尽管该标准没有指定任何特定的实现,但复杂性要求很大程度上要求有序关联容器作为平衡二叉树来实现。无序容器将键存储在哈希表中,因此顺序对您的代码来说并不重要,并且会随着您向容器中添加项而发生变化。
划分关联容器的另一种方法是划分为集合和贴图。集合就像数学集合:它们有成员,并且可以测试成员资格。映射就像存储键/值对的集合。集合和映射可能需要唯一的关键字,也可能允许重复的关键字。集合类型有set
(唯一键,已排序)multiset
(重复键,已排序)unordered_set
和unordered_multiset
。映射类型有map
、multimap
、unordered_map
和unordered_multimap
。
不同的容器有不同的特性。例如,vector
允许快速访问任何项目,但在中间插入会很慢。另一方面,list
提供了对任何项目的快速插入和删除,但只提供双向迭代器,不提供随机访问。
C++ 标准根据复杂性来定义容器特征,复杂性是用 big-O 符号写的。请记住,在你的算法入门课程中, O (1)是常量复杂度,但没有任何常量可能是什么的指示。 O ( n )是线性复杂度:如果容器有 n 个项目,执行一次 O ( n )操作所花费的时间与 n 成正比。对排序数据的操作往往是对数的: O (log n )。
表 57-1 总结了所有的容器及其特性。插入、删除和查找列显示了这些操作的平均复杂度,其中 N 是容器中元素的数量。查找序列容器意味着查找特定索引处的项目。对于关联容器,这意味着通过值查找特定的项。“否”表示容器根本不支持该操作。
表 57-1。
集装箱及其特征概述
|类型
|
页眉
|
插入
|
抹去
|
检查
|
迭代程序
|
| --- | --- | --- | --- | --- | --- |
| array
| <array>
| 不 | 不 | O① | 接触的 |
| deque
| <deque>
| O ( N )* | O ( N )* | O① | 随机存取 |
| forward_list
| <forward_list>
| O① | O① | O ( N | 向前 |
| list
| <list>
| O① | O① | O ( N | 双向的 |
| map
| <map>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| multimap
| <map>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| multiset
| <set>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| set
| <set>
| O (日志 N | O (日志 N | O (日志 N | 双向的 |
| unordered_map
| <unordered_map>
| O① | O① | O① | 向前 |
| unordered_multimap
| <unordered_map>
| O① | O① | O① | 向前 |
| unordered_multiset
| <unordered_set>
| O① | O① | O① | 向前 |
| unordered_set
| <unordered_set>
| O① | O① | O① | 向前 |
| vector
| <vector>
| O ( N )* | O ( N )* | O① | 接触的 |
*** 复杂度在容器中间插入和擦除为 O(N)但在容器末端为 O(1),当在许多操作中摊销时。deque 还允许在容器的开头进行摊销的 O(1)插入和擦除。
成员类型
每个容器都提供了许多有用的类型和类型定义作为容器的成员。本节经常使用其中的几个:
值类型
这是容器存储的类型的同义词。例如,vector<double>
的value_type
是double
,std::list<char>::value_type
是char
。使用标准成员类型使得编写和读取容器代码更加容易。本探索的其余部分广泛使用了value_type
。
映射的容器存储键/值对,所以map<Key, T>
(以及multimap
、unordered_map
和unordered_multimap
)的value_type
是std::pair<const Key, T>
。关键字类型是const
,因为在向关联容器添加一个项目后,您不能更改关键字。容器的内部结构取决于键,因此更改键会违反排序约束。
密钥类型
关联容器将key_type
声明为第一个模板参数的 typedef 例如,map<int, double>::key_type
是int
。对于器械包类型,key_type
和value_type
相同。
参考
这是引用value_type
的同义词。除了极少数情况,reference
与value_type&
相同。
常量 _ 引用
const_reference
是引用const value_type
的同义词。除了极少数情况,const_reference
与value_type const&
完全相同。
迭代程序
这是迭代器类型。它可能是 typedef,但更有可能是一个类,其定义是依赖于实现的。重要的是这种类型满足迭代器的要求。每个容器类型实现一个特定的迭代器类别,如表 57-1 所述。
常量迭代器
const_iterator
是const
项的迭代器类型。它可能是 typedef,但更有可能是一个类,其定义是依赖于实现的。重要的是这种类型符合const
项的迭代器的要求。每个容器类型实现一个特定的迭代器类别,如表 57-1 所述。
尺寸 _ 类型
size_type
是内置整数类型之一的 typedef(具体是哪种取决于实现)。它表示序列容器或容器大小的索引。
什么可以放进集装箱
为了在容器中存储项目,项目的类型必须满足一些基本要求。您必须能够复制或移动项目,并使用复制或移动来指定它。对于内置类型,这是自动的。对于一个类类型,你通常有这个能力。编译器甚至为您编写了构造器和赋值操作符。到目前为止,本书中的所有类都满足基本要求;在探索之前,你不必关心不一致的类。
序列容器本身不必比较项目是否相等;他们只是根据需要复制或移动元素。当他们不得不复印时,他们认为复印件和原件是一样的。
有序关联容器需要一个排序函子。默认情况下,它们使用一个名为std::less<key_type>
的标准仿函数,该仿函数又使用<
操作符。你可以提供一个定制的仿函数,只要它实现了严格弱排序,它由以下需求定义:
-
如果 a < b 和b<c,那么 a < c 。
-
一个 <一个一个总是假的。
-
项目存储在容器中后,顺序不会改变。
新 C++ 程序员的一个常见错误是违反规则 2,通常是通过实现<=
而不是<
。违反严格的弱排序规则会导致未定义的行为。一些库有一个调试模式,检查你的仿函数以确保它是有效的。如果你的库有这样的模式,使用它。
无序关联容器需要一个散列函子和一个相等函子。默认的哈希函子是std::hash<key_type>
(在<functional>
中声明)。标准库为内置类型和string
提供了特化。如果你在一个无序的容器中存储一个定制类,你必须提供你自己的散列函子。最简单的方法就是特化hash
。清单 57-1 展示了如何将hash
特化为rational
类型。您只需提供函数调用操作符,该操作符必须返回类型std::size_t
(一个实现定义的整数类型)。
import <functional>;
import rational;
namespace std {
template<class T>
class hash<rational<T>>
{
public:
std::size_t operator()(rational<T> const& r)
const
{
return hasher_(r.numerator() * r.denominator());
}
private:
std::hash<T> hasher_;
};
} // end of std
Listing 57-1.Specializing the hash Template for the rational Type
尽管标准库为所有内置类型提供了一个std::hash<>
特化,但它没有提供任何有效的方法来组合多个哈希值。清单 57-1 中显示的方法没有给出好的结果。(例如,1/2 和 2 共享相同的哈希值。)但是编写有效的哈希函数不在本书的讨论范围之内;有关编写更好的散列函数的信息,请访问该书的网站。
默认的等式仿函数是std::equal_to<T>
(在<functional>
中声明),它使用了==
操作符。如果两个项目相等,它们的哈希值也必须相等(但反过来就不一定了)。
当您在容器中插入项目时,容器会保留该项目的副本,或者您可以将对象移动到容器中。当您抹掉一个项目时,容器会销毁该项目。当你破坏一个容器时,它破坏了它的所有元素。下一节将详细讨论插入和擦除。
插入和擦除
我已经展示了一些在矢量和映射中插入和删除元素的例子。本节将更深入地探讨这个主题。除了array
和forward_list
之外,容器类型遵循一些基本模式。array
类型的大小是固定的,所以它不提供插入或擦除功能。而forward_list
有自己的做法,因为单链表不能直接插入或擦除项。所有其他容器都遵循本节中描述的规范。
在序列容器中插入
您可以选择几个成员函数来将项目插入到序列容器中。最基本的函数是emplace
,它在容器中构造一个条目。它具有以下形式:
-
迭代器就位(此处 const_iterator,args...)
-
将一个新构造的项插入到集合中的位置
here
之前,并返回一个指向新添加项的迭代器。args
可以是零个或多个传递给value_type
构造器的参数。如果here
是end()
,则在容器的末尾构造项目。 -
参考就位 _ 返回(args...)
-
将新构造的项追加到集合中,并返回对该构造项的引用。
args
可以是零个或多个传递给value_type
构造器的参数。允许快速插入集装箱前端的集装箱(deque
、list
)还有emplace_front()
。
如果有已经构造好的对象,调用insert
,它有四种重载形式:
-
迭代器插入(这里是 const_iterator,value_type item)
-
通过将
item
复制或移动到集合中紧靠位置here
之前的位置来插入它,并返回一个引用新添加的项的迭代器。如果here
是end()
,那么item
被追加到容器的末尾。 -
迭代器插入(这里是 const_iterator,size_type n,value_type const &项)
-
在
here
引用的位置之前插入item
的副本n
。如果here
是end()
,则项目被追加到容器的末尾。返回第一个插入项的迭代器。 -
迭代器插入(这里是 const_iterator,STD::initializer _ list
brace _ enclosed _ list) -
初始化列表是花括号中的值列表。编译器构造一个值范围,这个函数将这些值复制到容器中,从紧接在
here
之前的位置开始。返回第一个插入项的迭代器。 -
模板<类输入器>
迭代器插入(这里是 const_iterator,首先是 InputIterator,最后是 input iterator)
-
从紧接在
here
之前的位置开始,将范围first
、last
中的值复制到容器中。返回第一个插入项的迭代器。
从序列容器中擦除
函数的作用是擦除或删除容器中的项目。序列容器实现了两种形式的erase
:
-
迭代器擦除(const_iterator pos)
-
删除
pos
引用的项目,并返回一个引用后续项目的迭代器。如果最后一项被删除,则返回end()
。如果你试图删除end()
或者pos
是一个不同容器对象的迭代器,行为是未定义的。 -
迭代器擦除(const_iterator first,const_iterator last)
-
删除范围[
first
,last
]中的所有项,并返回一个迭代器,该迭代器指向紧跟在最后一个被删除项之后的项。如果容器中的最后一项被删除,则返回end()
。如果迭代器顺序错误或者引用了不同的容器对象,则行为是未定义的。
函数从容器中删除所有元素。除了基本的擦除功能,序列容器还提供pop_front
来擦除集合的第一个元素,提供pop_back
来擦除集合的最后一个元素。只有当容器能够以恒定的复杂性实现这两个功能时,它才能实现这两个功能。哪些序列容器实现了 pop_back
?
哪些序列容器实现了 pop_front
?
与就位功能一样,vector
提供pop_back
,list
和deque
同时提供pop_back
和pop_front
。
在关联容器中插入
关联容器的所有插入函数都遵循返回类型的通用模式。重复键容器(multimap
、multiset
、unordered_multimap
、unordered_multiset
)为新添加的项返回一个迭代器。唯一键容器(map
、set
、unordered_map
、unordered_set
)返回一个pair<iterator, bool>
:迭代器引用容器中的项目,如果项目被添加,则bool
为 true,如果项目已经存在,则bool
为 false。在本节中,返回类型显示为返回。如果该项目已经存在,则现有项目保持不变,新项目被忽略。
通过调用两个定位函数之一,在关联容器中构造一个新项:
-
返回
emplace(args...)
-
在容器中的正确位置构造一个新元素,将
args
传递给value_type
构造器。 -
iterator emplace_hint(iterator hint, args...)
-
构造一个尽可能靠近
hint
的新元素,将args
传递给value_type
构造器。 -
对于有序容器,如果该项的位置紧接在
hint
之后,则该项以恒定的复杂度添加。否则复杂度是对数的。如果您必须在一个有序容器中存储许多项,并且这些项已经按顺序排列,那么您可以通过使用最近插入的项的位置作为提示来节省一些时间。
与序列容器一样,您也可以调用insert
函数来插入到关联容器中。与序列容器的一个关键区别是,您不必提供位置(有一种形式允许您提供位置作为提示)。
-
返回 插入(value_type 项)
-
将
item
移动或复制到容器中。 -
迭代器插入(const_iterator 提示,value_type 项)
-
将
item
移动或复制到尽可能靠近hint
的容器中,如前面关于emplace_hint
的描述。 -
模板<类输入器>
void insert(先输入,后输入)
-
将范围[
first
,last
]中的值复制到容器中。对于有序容器,如果范围[first
,last
]已经排序,您将获得最佳性能。同样,没有范围形式的插入。
写一个程序,从标准输入中读取一串字符串到一组字符串中。使用emplace_hint
功能。保存返回值,以便在插入下一项时作为提示传递。找到一个大的字符串列表作为输入。将列表复制两份,一份按排序顺序,一份按随机顺序。(如果您需要帮助查找或准备输入文件,请访问本书的网站。)比较你的程序读取两个输入文件的性能。
编写相同程序的另一个版本,这次使用简单的单参数 emplace function
。再次用两个输入文件运行程序。比较所有四种变体的性能:有提示的和无提示的插入,排序的和未排序的输入。
清单 57-2 显示了使用emplace_hint
的程序的简单形式。
import <iostream>;
import <set>;
import <string>;
int main()
{
std::set<std::string> words{};
std::set<std::string>::iterator hint{words.begin()};
std::string word{};
while(std::cin >> word)
hint = words.emplace_hint(hint, std::move(word));
std::cout << "stored " << words.size() << " unique words\n";
}
Listing 57-2.Using a Hint Position when Inserting into a Set
当我用一个超过 200,000 字的文件运行程序时,带有排序输入的提示程序在大约 1.6 秒内执行。未打印的表格需要 2.2 秒。在随机输入的情况下,两个程序的运行时间约为 2.3 秒。正如您所看到的,当输入已经排序时,提示会产生影响。细节取决于库的实现;您的里程可能会有所不同。
基于节点的容器(set
、map
、list
、multiset
和multimap
)允许您从一个容器中提取节点,并将它们添加到另一个容器中。有关这些高级功能的详细信息,请参考语言参考。
从关联容器中擦除
函数的作用是擦除或删除容器中的项目。关联容器实现了三种形式的erase
:
-
迭代器擦除(const_iterator pos)
-
删除
pos
所指的项目;复杂性是不变的,可能会分摊到许多调用中。返回一个引用后继值的迭代器(或end()
)。如果pos
不是容器的有效迭代器,那么行为是未定义的。 -
迭代器擦除(const_iterator first,const_iterator last)
-
擦除范围[
first
、last
]中的所有项目。返回一个迭代器,该迭代器指向最后一个被擦除项之后的项。如果容器中的最后一项被删除,则返回end()
。如果[first
,last
]不是容器的有效迭代器范围,则行为未定义。 -
迭代器擦除(value_type const &值)
-
从容器中删除所有出现的
value
。返回擦除的项目数,可以为零。
与序列容器一样,clear()
删除容器中的所有元素。
例外
如果抛出异常,容器会尽力保持秩序。异常有两个潜在的来源:容器本身和容器中的项目。大多数成员函数不会抛出无效参数的异常,所以如果容器内存不足,无法插入新项,容器本身的异常最常见的来源是std::bad_alloc
。
如果您尝试将单个项插入到容器中,并且操作失败(可能是因为该项的复制构造器引发了异常,或者容器内存不足),则容器保持不变。
如果您尝试插入多个项,并且其中一个项在插入容器时引发异常(例如,该项的复制构造器引发异常),大多数容器不会回滚更改。只有list
和forward_list
类型回滚到它们的原始状态。其他容器以有效状态离开容器,并且已经成功插入的项目保留在容器中。
当删除一个或多个项目时,容器本身不会抛出异常,但是它们可能必须移动(或者在有序容器的情况下,比较)项目;如果一个项目的移动构造器抛出异常(极不可能的事件),擦除可能是不完整的。但是,无论如何,容器都保持有效状态。
为了使这些保证保持有效,析构函数不能抛出异常。
Tip
永远不要从析构函数中抛出异常。
迭代器和引用
使用容器时,我还没有提到的一个要点是迭代器和引用的有效性。问题是,当您在容器中插入或删除项目时,该容器的部分或全部迭代器可能会无效,并且对容器中项目的引用可能会无效。哪些迭代器和引用无效以及在什么情况下无效的细节取决于容器。
迭代器和引用失效反映了容器的内部结构。例如,vector
将其元素存储在一个连续的内存块中。因此,插入或删除任何元素都会移动更高索引处的所有元素,这将使更高索引处的所有迭代器和对这些元素的引用无效。随着一个vector
的增长,它可能不得不分配一个新的内部数组,这将使那个vector
的所有现存迭代器和引用失效。您永远不知道什么时候会发生这种情况,所以在向vector
添加项目时,最安全的做法是永远不要保留vector
的迭代器或引用。(但是如果您必须保留这些迭代器和引用,请在库引用中查找reserve
成员函数。)
另一方面,list
实现了一个双向链表。插入或删除一个元素只是插入或删除一个节点,对迭代器和对其他节点的引用没有影响。对于所有容器,如果你删除一个迭代器引用的节点,这个迭代器必然会失效,就像对被删除元素的引用必然会失效一样。
实际上,插入和删除元素时必须小心。这些函数通常返回迭代器,您可以用它们来帮助维护程序的逻辑。清单 57-3 显示了一个函数模板erase_unsorted
,它遍历一个容器并为任何大于其前面值的元素调用erase
。它是一个函数模板,可以处理任何满足序列容器要求的类。
template<class Container>
void erase_unsorted(Container& cont)
{
auto prev{cont.end()};
auto next{cont.begin()};
while (next != cont.end())
{
// invariant: std::is_sorted(cont.begin(), prev);
if (prev != cont.end() and *next < *prev)
next = cont.erase(next);
else
{
prev = next;
++next;
}
}
}
Listing 57-3.Erasing Elements from a Sequence Container
注意erase_less
如何在容器中移动迭代器iter
。prev
迭代器引用前一项(或者container.end()
,当循环第一次开始并且没有前一项时)。只要*prev
小于*iter
,就通过将prev
设置为iter
并增加iter
来推进循环。如果容器按升序排列,则不会发生任何变化。然而,如果项目不在适当的位置,则*prev < *iter
为假,并且位置iter
处的项目被擦除。erase
返回的值是一个迭代器,该迭代器引用了iter
被擦除之前的项。这正是我们想要iter
指向的地方,所以我们只需将iter
设置为返回值,并让循环继续。
编写一个测试程序,看看 erase_unsorted
如何处理一个列表和一个向量。确保它适用于升序数据、降序数据和混合数据。清单 57-4 显示了我的简单测试程序。
import <algorithm>;
import <iostream>;
import <initializer_list>;
import <iterator>;
import <ranges>;
import <vector>;
import erase_unsorted; // Listing 57-3
/// Print items from a container to the standard output.
template<class Container>
requires std::ranges::range<Container>
void print(std::string const& label, Container const& container)
{
std::cout << label;
using value_type = std::ranges::range_value_t<Container>;
std::ranges::copy(container,
std::ostream_iterator<value_type>(std::cout, " "));
std::cout << '\n';
}
/// Test erase_unsorted by extracting integers from a string into a container
/// and calling erase_unsorted. Print the container before and after.
/// Double-check that the same results obtain with a list and a vector.
void test(std::initializer_list<int> numbers)
{
std::vector<int> data{numbers};
erase_unsorted(data);
if (not std::is_sorted(data.begin(), data.end()))
print("FAILED", data);
}
int main()
{
test({2, 3, 7, 11, 13, 17, 23, 29, 31, 37});
test({37, 31, 29, 23, 17, 13, 11, 7, 3, 2});
test({});
test({42});
test({10, 30, 20, 40, 0, 50});
}
Listing 57-4.Testing the erase_unsorted Function Template
erase_unsorted
函数的净效果是让容器保持有序。所以test()
函数调用std::is_sorted
来验证这个函数确实被排序了。如果没有,它会打印一条消息和一个用于调试的数字列表。这些测试包括已经按顺序排列的数字序列(包括一元序列和空序列)、逆序序列和混合序列。
序列容器
在本书中,容器最常见的用法是在vector
的末尾添加条目。然后,程序可能会使用标准算法来改变顺序,比如按升序排序、按随机顺序洗牌等等。除了vector
,其他的序列容器还有array
、deque
和list
。
序列容器的主要区别在于它们的复杂性特征。如果你经常需要从序列中间插入和删除,你可能需要list
。如果只需插入和擦除一端,使用vector
。如果容器的大小是一个固定的编译时常量,使用array
。如果序列的元素必须连续存储(在单个内存块中),使用array
或vector
。
以下各节包括了关于每种容器类型的更多细节。每个部分都提供了相同的程序进行比较。该程序构建一副扑克牌,然后随机选择一张牌给自己,一张牌给你。价值最高的牌获胜。程序播放十次,然后退出。该程序无需替换即可玩,也就是说,它不会在每次游戏结束后将用过的牌放回牌堆。为了随机挑选一张牌,程序使用了清单 45-5 中的randomint
类。将类别定义保存在名为randomint.hpp
的文件中,或者从书籍网站下载该文件。清单 57-5 显示了示例程序使用的card
类。关于完整的类定义,请从该书的网站下载card.hpp
。
export module cards;
import <iosfwd>;
/// Represent a standard western playing card.
export class card
{
public:
using suit = char;
static constexpr suit const spades {4};
static constexpr suit const hearts {3};
static constexpr suit const clubs {2};
static constexpr suit const diamonds {1};
using rank = char;
static constexpr rank const ace {14};
static constexpr rank const king {13};
static constexpr rank const queen {12};
static constexpr rank const jack {11};
constexpr card() : rank_{0}, suit_{0} {}
constexpr card(rank r, suit s) : rank_{r}, suit_{s} {}
constexpr void assign(rank r, suit s);
constexpr suit get_suit() const { return suit_; }
constexpr rank get_rank() const { return rank_; }
private:
rank rank_;
suit suit_;
};
export bool operator==(card a, card b);
export bool operator!=(card a, card b);
export std::ostream& operator<<(std::ostream& out, card c);
export std::istream& operator>>(std::istream& in, card& c);
/// In some games, Aces are high. In other Aces are low. Use different
/// comparison functors depending on the game.
export bool acehigh_less(card a, card b);
export bool acelow_less(card a, card b);
/// Generate successive playing cards, in a well-defined order,
/// namely, 2-10, J, Q, K, A. Diamonds first, then Clubs, Hearts, and Spades.
/// Roll-over and start at the beginning again after generating 52 cards.
export class card_generator
{
public:
card_generator();
card operator()();
private:
card card_;
}
;
Listing 57-5.The card Class, to Represent a Playing Card
数组类模板
array
类型是固定大小的容器,所以不能调用insert
或erase
。要使用array
,请将基本类型和大小指定为编译时常量表达式,如下所示:
std::array<double, 5> five_elements;
如果用比数组大小更少的值初始化数组,剩余的值将被初始化为零。如果完全省略了初始值设定项,如果值类型是类类型,编译器将调用默认初始值设定项;否则,它保持数组元素未初始化。因为一个数组不能改变大小,所以你不能在玩完牌后简单地把牌擦掉。为了保持代码简单,程序会在每次游戏结束后将卡片放回卡片组。清单 57-6 显示了替换的大牌程序。
import <algorithm>;
import <array>;
import <iostream>;
import card;
import randomint; // Listing 45-5
int main()
{
std::array<card, 52> deck;
std::ranges::generate(deck, card_generator{});
randomint picker{0, deck.size() - 1};
for (int i{0}; i != 10; ++i)
{
card const& computer_card{deck.at(picker())};
std::cout << "I picked " << computer_card << '\n';
card const& user_card{deck.at(picker())};
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-6.Playing High-Card with array
deque 类模板
一个deque
(读作“deck”)代表一个双端队列。从开头或结尾插入和删除速度很快,但是如果必须在其他地方插入或删除,复杂度是线性的。大多数时候,你可以像使用vector
一样使用deque
,所以应用你使用 vector
的经验来编写大牌程序。不替换玩法:即每局游戏结束后,通过将两张牌从容器中擦除的方式将其丢弃。清单 57-7 展示了我如何使用deque
编写大牌程序。
import <algorithm>;
import <deque>;
import <iostream>;
import card;
import randomint;
int main()
{
std::deque<card> deck(52);
std::ranges::generate(deck, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin() + randomint{0, deck.size()-1}()};
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin() + randomint{0, deck.size() - 1}();
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-7.Playing High-Card with a deque
列表类模板
一个list
代表一个双向链表。在列表中的任何点插入和擦除都很快,但不支持随机访问。因此,high-card 程序使用迭代器和advance
函数(探索 46 )。编写高卡程序使用 list
。将你的解决方案与清单 57-8 中我的解决方案进行比较。
import <algorithm>;
import <iostream>;
import <list>;
import card;
import randomint;
int main()
{
std::list<card> deck(52);
std::ranges::generate(deck, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = std::next(deck.begin(), randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-8.Playing High-Card with a list
deque
类型支持随机访问迭代器,所以它可以给begin()
加一个整数来挑选一张牌。但是list
使用双向迭代器,所以必须调用advance()
或者next()
;清单 57-8 展示了这两者。注意,您也可以为deque
s 调用advance()
或next()
,并且实现仍然使用加法。
vector 类模板
vector
是一个可以在运行时改变大小的数组。追加到末尾或从末尾擦除速度很快,但在矢量中的任何其他位置插入或擦除时,复杂度是线性的。对比 deque
和 list
版本的高卡程序。选择您喜欢的一个并修改它以与 vector
一起工作。清单 57-9 中显示了我的程序版本。
import <algorithm>;
import <iostream>;
import <vector>;
import card;
import randomint;
int main()
{
std::vector<card> deck(52);
std::ranges::generate(deck, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin() + randomint{0, deck.size()-1}()};
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin() + randomint{0, deck.size() - 1}();
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-9.Playing High-Card with vector
注意你如何改变程序来使用vector
而不是deque
,仅仅通过改变类型名。它们的用法非常相似。一个关键的区别是deque
在容器的开头提供快速(恒定复杂度)插入,这是vector
所缺乏的。另一个关键区别是vector
支持连续迭代器,而 deque 使用随机访问迭代器。这两个因素在这里都不重要。
关联容器
关联容器通过控制容器中元素的顺序来提供快速插入、删除和查找。有序关联容器将元素存储在树中,由比较仿函数(默认为std::less
,它使用<
)排序,因此插入、删除和查找以对数复杂度发生。无序容器使用哈希表(根据调用者提供的哈希函子和等式函子)进行访问,在一般情况下具有恒定的复杂性,但在最坏情况下具有线性复杂性。有关树和散列表的更多信息,请查阅任何关于数据结构和算法的教科书。
设置存储键,并映射存储键/值对。多重集和多重映射允许重复键。所有等价密钥都存储在容器中的相邻位置。普通集合和映射需要唯一的键。如果您尝试插入容器中已经存在的密钥,则不会插入新密钥。请记住,有序容器中的等价仅由调用比较函子来确定:compare(a, b)
为假,compare(b, a)
为假意味着a
和b
等价。
无序容器调用它们的相等函子来确定一个键是否重复。默认为std::equal_to
(在<functional>
中声明),使用==
操作符。
因为关联数组存储键的顺序取决于键的内容,所以不能修改存储在关联容器中的键的内容。这意味着你不能使用关联容器的迭代器作为输出迭代器。因此,如果您想使用关联容器实现 high-card 程序,您可以使用inserter
函数创建一个填充容器的输出迭代器。清单 57-10 展示了如何使用set
来实现高卡程序。
import <algorithm>;
import <iostream>;
import <iterator>;
import <set>;
import <utility>;
import card;
import randomint;
int main()
{
using cardset = std::set<card, std::function<bool(card, card)>>;
cardset deck(acehigh_less);
std::generate_n(std::inserter(deck, deck.begin()), 52, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin();
std::advance(pick, randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-10.Playing High-Card with set
使用关联容器时,当您使用自定义比较仿函数(对于有序容器)或自定义相等和散列仿函数(对于无序容器)时,可能会遇到一些困难。您必须将函子类型指定为模板参数。构造容器对象时,将仿函数作为参数传递给构造器。函子必须是您在模板特化中指定的类型的实例。
例如,清单 57-10 使用了acehigh_less
函数,并将其传递给deck
的构造器。因为acehigh_less
是一个函数,所以必须指定一个函数类型作为模板参数。声明函数类型最简单的方法是使用std::function
模板。模板参数看起来有点像一个无名的函数头—提供返回类型和参数类型:
std::function<bool(card, card)>
另一种方法是特化类型card
的std::less
类模板。显式特化将实现函数调用操作符来调用acehigh_less
。利用特化,您可以使用默认的模板参数和构造器参数。遵循<functional>
标题中的函子模式。函子应该提供一个函数调用操作符,该操作符使用参数和返回类型,并为容器实现严格的弱排序函数。清单 57-11 展示了另一个版本的大牌程序,这次使用了less
的特化。唯一真正的区别是如何初始化甲板。
import <algorithm>;
import <functional>;
import <iostream>;
import <iterator>;
import <set>;
import card;
import randomint;
namespace std
{
template<>
class less<card>
{
public:
bool operator()(card a, card b) const { return acehigh_less(a, b); }
};
}
int main()
{
using cardset = std::set<card>;
cardset deck{};
std::generate_n(std::inserter(deck, deck.begin()), 52, card_generator{});
for (int i{0}; i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin();
std::advance(pick, randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-11.Playing High-Card Using an Explicit Specialization of std::less
要使用无序容器,必须编写一个显式的std::hash<card>
特化。清单 57-1 应该能帮上忙。卡模块已经为卡声明了operator==
,所以你要准备好最后一次重写高卡程序,这次是为 unordered_set
。将您的解决方案与清单 57-12 进行比较。尽管容器类型不同,但所有这些程序都非常相似,这是通过明智地使用迭代器、算法和函子而实现的。
import <algorithm>;
import <functional>;
import <iostream>;
import <iterator>;
import <unordered_set>;
import card;
import randomint;
namespace std
{
template<>
class hash<card>
{
public:
std::size_t operator()(card a)
const
{
return hash<int>{}(a.get_suit() * 64 + a.get_rank());
}
};
} // namespace std
int main()
{
using cardset = std::unordered_set<card>;
cardset deck{};
std::generate_n(std::inserter(deck, deck.begin()), 52, card_generator{});
for (int i(0); i != 10; ++i)
{
auto pick{deck.begin()};
std::advance(pick, randomint{0, deck.size() - 1}());
card computer_card{*pick};
deck.erase(pick);
std::cout << "I picked " << computer_card << '\n';
pick = deck.begin();
std::advance(pick, randomint{0, deck.size() - 1}());
card user_card{*pick};
deck.erase(pick);
std::cout << "You picked " << user_card << '\n';
if (acehigh_less(computer_card, user_card))
std::cout << "You win.\n";
else
std::cout << "I win.\n";
}
}
Listing 57-12.Playing High-Card with unordered_set
在接下来的探索中,你将踏上一个完全不同的旅程,一个涉及到异国情调的地方,当地人说异国情调的语言,使用异国情调的字符集的世界旅行。这个旅程还涉及到模板的新的有趣的用途。
五十八、区域设置和方面
正如你在 Exploration 18 中看到的,C++ 提供了一个复杂的系统来支持你的代码的国际化和本地化。即使您不打算将程序翻译成多种语言,您也必须了解 C++ 使用的语言环境机制。事实上,您一直在使用它,因为 C++ 总是通过 locale 系统发送格式化的 I/O。这种探索将帮助您更好地理解区域设置,并在您的程序中更有效地使用它们。
问题
巴别塔的故事引起了程序员的共鸣。想象一个说单一语言和使用单一字母表的世界。如果我们不必处理字符集问题、语言规则或地区,编程将会简单得多。
现实世界有许多语言、无数的字母和音节表以及众多的字符集,所有这些都使生活更加丰富和有趣,也使程序员的工作更加困难。不管怎样,我们程序员必须应付。这并不容易,这次探索不能给你所有的答案,但这是一个开始。
不同的文化、语言和字符集会产生不同的信息呈现和解释方法、不同的字符代码解释(正如您在 Exploration 18 中了解到的),以及不同的信息组织(尤其是分类)方式。即使是数字数据,您可能会发现,根据当地的环境、文化和语言,您必须以几种方式书写相同的数字。表格 58-1 展示了一些根据不同文化、习俗和地区书写数字的方法。
表 58-1。
写数字的各种方法
|数字
|
文化
|
| --- | --- |
| 123456.7890 | 默认 C++ |
| 123,456.7890 | 美国 |
| 123 456.7890 | 国际科学 |
| 卢比 1,23,456.7890 | 印度货币 * |
| 123.456,7890 | 德国 |
*** 是的,逗号是正确的。
其他文化差异包括
-
12 小时对。24 小时制
-
时区
-
夏令时实践
-
重音字符相对于非重音字符是如何排序的(
'a'
在'á'
之前还是之后)?) -
日期格式:月/日/年、日/月/年或年-月-日
-
货币格式(123,456 或 99)
不知何故,糟糕的应用程序程序员必须弄清楚什么是文化相关的,收集应用程序可能运行的所有可能的文化的信息,并在应用程序中适当地使用这些信息。幸运的是,艰苦的工作已经为您完成了,并且是 C++ 标准库的一部分。
救援地点
C++ 使用一个名为 locales 的系统来管理这种风格差异。探索 18 引入了语言环境作为组织字符集及其属性的手段。地区还组织数字、货币、日期和时间的格式(加上一些我不会深入讨论的东西)。
C++ 定义了一个基本的语言环境,称为经典语言环境,它提供了最少的格式。每个 C++ 实现都可以自由地提供额外的语言环境。每个语言环境通常都有一个名称,但是 C++ 标准并没有强制要求任何特定的命名约定,这使得编写可移植代码变得很困难。您只能依赖两个标准名称:
-
经典的地点被命名为
"C"
。经典语言环境为所有实现指定了相同的基本格式信息。当程序启动时,经典区域设置是初始区域设置。 -
空字符串(
""
)表示默认的或本地语言环境。默认区域设置从主机操作系统获取格式和其他信息,获取方式取决于操作系统所能提供的内容。对于传统的桌面操作系统,您可以假设默认区域设置指定了用户首选的格式规则和字符集信息。对于其他环境,如嵌入式系统,默认的语言环境可能与经典的语言环境相同。
许多 C++ 实现使用 ISO 和 POSIX 标准来命名地区:语言的 ISO 639 代码(例如,fr
代表法语,en
代表英语,ko
代表韩语),可选地后跟下划线和地区的 ISO 3166 代码(例如,CH
代表瑞士,GB
代表英国,HK
代表香港)。该名称可选地后跟一个点和字符集的名称(例如,utf8
用于 Unicode UTF-8,Big5
用于中文 Big 5 编码)。因此,我使用en_US.utf8
作为我的默认语言环境。一个台湾本地人可能会用zh_TW.Big5
;瑞士法语区的开发者可能会使用fr_CH.latin1
。阅读您的库文档,了解它是如何指定区域名称的。您的默认区域设置是什么?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _它的主要特点是什么?
每个 C++ 应用程序都有一个全局locale
对象。除非您显式地更改流的区域设置,否则它会从全局区域设置开始。(如果您稍后更改了全局语言环境,这不会影响已经存在的流,例如标准 I/O 流。)最初,全局语言环境是经典语言环境。经典语言环境在任何地方都是一样的(除了依赖于字符集的部分),所以程序在经典语言环境下具有最大的可移植性。另一方面,它有最低限度的地方风味。下一节将探讨如何改变流的语言环境。
区域设置和 I/O
回想一下 Exploration 18 中,您在流中注入了一个语言环境,以便根据语言环境的规则格式化 I/O。因此,为了确保以经典语言环境读取输入,并以用户的本地语言环境打印结果,您需要:
std::cin.imbue(std::locale::classic()); // standard input uses the classic locale
std::cout.imbue(std::locale{""}); // imbue with the user's default locale
标准的 I/O 流最初使用经典的语言环境。您可以在任何时候用新的语言环境来填充流,但是在执行任何 I/O 之前这样做最有意义。
通常,在读取或写入文件时,您会使用经典区域设置。您通常希望文件的内容是可移植的,并且不依赖于用户的操作系统偏好。对于控制台或 GUI 窗口的短暂输出,您可能希望使用默认的区域设置,这样用户可以最舒适地阅读和理解它。另一方面,如果另一个程序可能试图读取您程序的输出(就像 UNIX 管道和过滤器一样),您应该坚持使用传统的语言环境,以确保可移植性和通用格式。如果您准备在 GUI 中显示输出,请务必使用默认的语言环境。
面状
流解释数字输入和格式化数字输出的方式是通过请求被灌输的语言环境。一个对象是一个片段的集合,每个片段管理国际化的一个小方面。例如,一个名为numpunct
的组件提供了数字格式的标点符号,比如小数点字符(在美国是'.'
,但在法国是','
)。另一个片段,num_get
,使用从numpunct
获得的信息,从流中读取并解析文本以形成一个数字。num_get
和numpunct
等棋子称为刻面。
对于普通的数值 I/O,您永远不必处理刻面。I/O 流自动为您管理这些细节:operator<<
函数使用num_put
方面来格式化输出的数字,而operator>>
使用num_get
将文本解释为数字输入。对于货币、日期和时间,I/O 操纵器使用刻面来格式化值。但是有时候你需要自己使用刻面。你在探索 18 中学到的isalpha
、toupper
和其他与角色相关的功能都使用ctype
刻面。任何必须进行大量字符测试和转换的程序都可以通过直接管理其方面而受益。
像字符串和 I/O 流一样,刻面是类模板,在字符类型上参数化。到目前为止,你唯一用过的字符类型是char
;你将在探索中了解其他角色类型。不管字符类型如何,原则都是一样的(这就是刻面使用模板的原因)。
要从一个地区获得一个方面,调用use_facet
函数模板。模板参数是你寻找的 facet,函数参数是locale
对象。返回的方面是const
,不可复制,所以使用结果的最佳方式是初始化一个const
引用,如下所示:
auto const& mget{ std::use_facet<std::money_get<char>>(std::locale{""}) };
从内向外读取,名为mget
的对象被初始化为调用use_facet
函数的结果,该函数请求对money_get<char>
方面的引用。默认语言环境作为唯一的参数传递给use_facet
函数。mget
的类型是对const money_get<char>
刻面的引用。一开始读起来有点令人生畏,但你最终会习惯的。
直接使用刻面可能会很复杂。幸运的是,标准库提供了一些 I/O 操纵器(在<iomanip>
中声明)来简化时间和货币方面的使用。清单 58-1 展示了一个简单的程序,它将标准 I/O 流融入本地语言环境,然后读取和写入货币值。
import <iomanip>;
import <iostream>;
import <locale>;
import <string>;
int main()
{
std::locale native{""};
std::cin.imbue(native);
std::cout.imbue(native);
std::cin >> std::noshowbase; // currency symbol is optional for input
std::cout << std::showbase; // always write the currency symbol for output
std::string digits;
while (std::cin >> std::get_money(digits))
{
std::cout << std::put_money(digits) << '\n';
}
if (not std::cin.eof())
std::cout << "Invalid input.\n";
}
Listing 58-1.Reading and Writing Currency Using the Money I/O Manipulators
区域设置操纵器像其他操纵器一样工作,但是它们调用相关的方面。操纵器使用流来处理错误标志、迭代器、填充字符等等。get_time
和put_time
操纵器读取和写入日期和时间;详情请查阅库参考资料。
字符类别
这一部分继续你在 18 中开始的字符集和区域设置的检查。除了测试字母数字字符或小写字符,您还可以测试几种不同的类别。表 58-2 列出了所有的分类函数以及它们在经典语言环境中的行为。都是以一个字符作为第一个参数,以一个locale
作为第二个参数;它们都返回一个bool
结果。
表 58-2。
字符分类功能
|功能
|
描述
|
经典区域设置
|
| --- | --- | --- |
| isalnum
| 含字母和数字的 | 'a'
–'z'
、'A'
–'Z'
、'0'
–'9'
|
| isalpha
| 字母的 | 'a'
–'z'
,'A'
–'Z'
|
| iscntrl
| 控制 | 任何不可打印的字符 |
| isdigit
| 手指 | '0'
–'9'
(所有地区) |
| isgraph
| 图解的 | 除' '
以外的可打印字符 |
| islower
| 小写字母 | 'a'
–'z'
|
| isprint
| 可印刷的 | 字符集 中的任何可打印字符 |
| ispunct
| 标点 | 除字母数字或空白以外的可打印字符 |
| isspace
| 空格 | ' '
、'\f'
、'\n'
、'\r'
、'\t'
、'\v'
|
| isupper
| 大写字母 | 'A'
–'Z'
|
| isxdigit
| 十六进制数字 | 'a'
–'f'
、'A'
–'F'
、'0'
–'9'
(所有地区) |
* 行为取决于字符集,即使在经典的语言环境中也是如此。
经典语言环境对一些类别有固定的定义(比如isupper
)。然而,其他地区可以扩展这些定义以包含其他字符,这些字符可能(也可能)依赖于字符集。只有isdigit
和isxdigit
对所有地区和所有字符集都有固定的定义。
然而,即使在经典的语言环境中,一些函数的精确实现,比如isprint
,也依赖于字符集。例如,在流行的 ISO 8859-1 (Latin-1)字符集中,'\x80'
是一个控制字符,但是在同样流行的 Windows-1252 字符集中,它是可打印的。在 UTF-8 中,'\x80'
是无效的,所以所有的分类函数都将返回false
。
语言环境和字符集之间的交互是 C++ 表现不佳的地方之一。区域设置可以随时更改,这可能会设置一个新的字符集,从而赋予某些字符值新的含义。但是,编译器对字符集的看法是固定的。例如,编译器将'A'
视为大写罗马字母 A ,并根据其运行时字符集的概念编译数字代码。该数值将永远固定不变。如果特征函数使用相同的字符集,一切都很好。isalpha
和isupper
函数返回真;isdigit
返回 false 这个世界一切都好。如果用户更改了区域设置,从而更改了字符集,那么这些函数可能不再适用于该字符变量。
让我们考虑一个具体的例子,如清单 58-2 所示。此程序对区域名称进行编码,这可能不适合您的环境。阅读评论,看看您的环境是否可以支持相同类型的语言环境,尽管名称不同。你将需要清单 40-4 中的ioflags
类。将类复制到它自己的模块ioflags
中,或者从书的网站下载文件。在阅读清单 58-2 ,之后,您期望的结果是什么?
import <format>;
import <iostream>;
import <locale>;
import <ostream>;
import ioflags; // from Listing 40-4
/// Print a character's categorization in a locale.
void print(int c, std::string const& name, std::locale loc)
{
// Don't concern yourself with the & operator. I'll cover that later
// in the book, in Exploration 63\. Its purpose is just to ensure
// the character's escape code is printed correctly.
std::cout << std::format("\\x{:02x} is {} in {}\n", c & 0xff, name, loc.name());
}
/// Test a character's categorization in the locale, @p loc.
void test(char c, std::locale loc)
{
ioflags save{std::cout};
if (std::isalnum(c, loc))
print(c, "alphanumeric", loc);
else if (std::iscntrl(c, loc))
print(c, "control", loc);
else if (std::ispunct(c, loc))
print(c, "punctuation", loc);
else
print(c, "none of the above", loc);
}
int main()
{
// Test the same code point in different locales and character sets.
char c{'\xd7'};
// ISO 8859-1 is also called Latin-1 and is widely used in Western Europe
// and the Americas. It is often the default character set in these regions.
// The country and language are unimportant for this test.
// Choose any that support the ISO 8859-1 character set.
test(c, std::locale{"en_US.iso88591"});
// ISO 8859-5 is Cyrillic. It is often the default character set in Russia
// and some Eastern European countries. Choose any language and region that
// support the ISO 8859-5 character set.
test(c, std::locale{"ru_RU.iso88595"});
// ISO 8859-7 is Greek. Choose any language and region that
// support the ISO 8859-7 character set.
test(c, std::locale{"el_GR.iso88597"});
// ISO 8859-8 contains some Hebrew
. The character set is no longer widely used.
// Choose any language and region that support the ISO 8859-8 character set.
test(c, std::locale{"he_IL.iso88598"});
}
Listing 58-2.Exploring Character Sets and Locales
你得到的实际回应是什么?
如果您在识别区域名称或运行该程序时遇到其他问题,以下是我在系统上运行该程序时得到的结果:
\xd7 is punctuation in en_US.iso88591
\xd7 is alphanumeric in ru_RU.iso88595
\xd7 is alphanumeric in el_GR.iso88597
\xd7 is none of the above in he_IL.iso88598
正如您所看到的,相同的字符有不同的类别,这取决于地区的字符集。现在假设用户输入了一个字符串,你的程序存储了这个字符串。如果您的程序更改了全局区域设置或用于处理该字符串的区域设置,您最终可能会误解该字符串。
在清单 58-2 中,分类函数在每次被调用时都会重新加载它们的方面,但是你可以重写程序,让它只加载一次它的方面。字符型刻面叫做ctype
。它有一个名为is
的函数,将类别掩码和字符作为参数,如果字符在掩码中有类型,则返回一个bool
: true。屏蔽值在std::ctype_base
中指定。
Note
请注意标准库自始至终使用的约定。当类模板需要助手类型和常量时,它们在非模板基类中声明。类模板派生自基类,因此可以轻松访问类型和常量。调用方通过用基类名称限定来获得对类型和常量的访问权。通过避免在基类中使用模板,标准库避免了仅仅为了使用与模板参数无关的类型或常量而进行的不必要的实例化。
掩码名称与分类函数相同,但没有前导的is
。清单 58-3 展示了如何重写简单的字符集演示来使用一个缓存的ctype
方面。
import <format>;
import <iostream>;
import <locale>;
import ioflags; // from Listing 40-4
void print(int c, std::string const& name, std::locale loc)
{
// Don't concern yourself with the & operator. I'll cover that later
// in the book. Its purpose is just to ensure the character's escape
// code is printed correctly.
std::cout << std::format("\\x{:02x} is {} in {}\n", c & 0xff, name, loc.name());
}
/// Test a character's categorization in the locale, @p loc.
void test(char c, std::locale loc)
{
ioflags save{std::cout};
std::ctype<char> const& ctype{std::use_facet<std::ctype<char>>(loc)};
if (ctype.is(std::ctype_base::alnum, c))
print(c, "alphanumeric", loc);
else if (ctype.is(std::ctype_base::cntrl, c))
print(c, "control", loc);
else if (ctype.is(std::ctype_base::punct, c))
print(c, "punctuation", loc);
else
print(c, "none of the above", loc);
}
int main()
{
// Test the same code point
in different locales and character sets.
char c{'\xd7'};
// ISO 8859-1 is also called Latin-1 and is widely used in Western Europe
// and the Americas. It is often the default character set in these regions.
// The country and language are unimportant for this test.
// Choose any that support the ISO 8859-1 character set.
test(c, std::locale{"en_US.iso88591"});
// ISO 8859-5 is Cyrillic. It is often the default character set in Russia
// and some Eastern European countries. Choose any language and region that
// support the ISO 8859-5 character set.
test(c, std::locale{"ru_RU.iso88595"});
// ISO 8859-7 is Greek. Choose any language and region that
// support the ISO 8859-7 character set.
test(c, std::locale{"el_GR.iso88597"});
// ISO 8859-8 contains some Hebrew. It is no longer widely used.
// Choose any language and region that support the ISO 8859-8 character set.
test(c, std::locale{"he_IL.iso88598"});
}
Listing 58-3.Caching the ctype Facet
ctype
方面还使用toupper
和tolower
成员函数执行大小写转换,这两个函数接受一个字符参数并返回一个字符结果。回忆探险第 22 期的计字题。重写您的解决方案(参见清单 23-2 和23-3)并更改 sanitize()函数以使用缓存的 facet 。我建议用一个sanitizer
类替换这个函数,这样这个类就可以在数据成员中存储这个方面。将你的程序与清单 58-4 进行比较。
import <format>;
import <iostream>;
import <locale>;
import <map>;
import <ranges>;
import <string>;
import <string_view>;
using count_map = std::map<std::string, int>; ///< Map words to counts
using count_pair = count_map::value_type; ///< pair of a word and a count
using str_size = std::string::size_type; ///< String size type
void initialize_streams()
{
std::cin.imbue(std::locale{});
std::cout.imbue(std::locale{});
}
class sanitizer
{
public:
sanitizer(std::locale const& locale)
: ctype_{ std::use_facet<std::ctype<char>>(locale) }
{}
bool keep(char ch) const { return ctype_.is(ctype_.alnum, ch); }
char tolower(char ch) const { return ctype_.tolower(ch); }
std::string operator()(std::string_view str)
const
{
auto data{ str
| std::ranges::views::filter(this { return keep(ch); })
| std::ranges::views::transform(this { return tolower(ch); }) };
return std::string{ std::ranges::begin(data), std::ranges::end(data) };
}
private:
std::ctype<char> const& ctype_;
};
str_size get_longest_key(count_map const& map)
{
str_size result{0};
for (auto const& pair : map)
if (pair.first.size() > result)
result = pair.first.size();
return result;
}
void print_pair(count_pair const& pair, str_size longest)
{
int constexpr count_size{10}; // Number of places for printing the count
std::cout << std::format("{0:{1}} {2:{3}}\n", pair.first, longest, pair.second, count_size);
}
void print_counts(count_map const& counts)
{
auto longest{get_longest_key(counts)};
// For each word/count pair...
for (count_pair pair: counts)
print_pair(pair, longest);
}
int main()
{
// Set the global locale to the native locale.
std::locale::global(std::locale{""});
initialize_streams();
count_map counts{};
sanitizer sanitize{std::locale{""}};
// Read words from the standard input and count the number of times
// each word occurs.
std::string word{};
while (std::cin >> word)
{
std::string copy{sanitize(word)};
// The "word" might be all punctuation, so the copy would be empty.
// Don't count empty strings.
if (not copy.empty())
++counts[copy];
}
print_counts(counts);
}
Listing 58-4.Counting Words Again, This Time with Cached Facets
请注意,程序的大部分内容都没有改变。在我的系统上,缓存ctype
facet 的简单行为减少了这个程序大约 15%的运行时间。
校对顺序
可以将关系运算符(如<
)与字符和字符串一起使用,但它们实际上并不比较字符或码位;他们比较存储单元。大多数用户并不关心一个名字列表是否按照存储单元按数字升序排序。他们想要一个按照他们自己的排序规则按字母升序排列的名字列表。
比如哪个先来:昂斯特伦还是角度?答案取决于你住在哪里,说什么语言。在斯堪的纳维亚,角度在前,昂斯特伦在斑马后。collate
方面根据地区的规则比较字符串。它的compare
函数使用起来有些笨拙,所以locale
类模板提供了一个简单的接口来确定一个string
是否比另一个少:使用locale
的函数调用操作符。换句话说,您可以使用一个locale
对象本身作为标准算法的比较函子,比如sort
。清单 58-5 显示了一个程序,该程序演示了排序规则如何依赖于区域设置。为了让程序在您的环境中运行,您可能需要更改区域名称。
import <algorithm>;
import <iostream>;
import <iterator>;
import <locale>;
import <string>;
import <vector>;
void sort_words(std::vector<std::string> words, std::locale loc)
{
std::ranges::sort(words, loc);
std::cout << loc.name() << ":\n";
std::ranges::copy(words,
std::ostream_iterator<std::string>(std::cout, "\n"));
}
int main()
{
std::vector<std::string> words{
"circus",
"\u00e5ngstrom", // ångstrom
"\u00e7irc\u00ea", // çircê
"angle",
"essen",
"ether",
"\u00e6ther", // æther
"aether",
"e\u00dfen" // eßen
};
sort_words(words, std::locale::classic());
sort_words(words, std::locale{"en_GB.utf8"}); // Great Britain
sort_words(words, std::locale{"no_NO.utf8"}); // Norway
}
Listing 58-5.Demonstrating How Collation Order Depends on Locale
\uNNNN
字符是一种表达 Unicode 字符的可移植方式。NNNN
必须是四个十六进制数字,指定一个 Unicode 码位。在接下来的探索中你会学到更多。
粗体行显示了如何使用locale
对象作为比较函子对单词进行排序。表 58-3 列出了我在每个地区得到的结果。根据您的本地字符集,您可能会得到不同的结果。
表 58-3。
各种语言环境的归类顺序
|经典的
|
大不列颠
|
挪威
|
| --- | --- | --- |
| aether
| aether
| aether
|
| angle
| æther
| angle
|
| circus
| angle
| çircê
|
| essen
| ångstrom
| circus
|
| ether
| çircê
| essen
|
| eßen
| circus
| eßen
|
| ångstrom
| essen
| ether
|
| æther
| eßen
| æther
|
| çircê
| ether
| ångstrom
|
下一篇文章将深入探讨 Unicode、国际字符集以及相关的挑战。
五十九、国际字符
探索 17-19 讨论了字符,但只是暗示了更大的事情即将到来。Exploration 58 开始从地区和方面来研究这些更大的问题。下一个要探讨的主题是如何在国际环境中讨论字符集和字符编码。
这个探索引入了宽字符,它类似于普通的(或窄)字符,除了它们通常占用更多的内存。这意味着宽字符类型可能比普通的char
表示更多的字符。在探索宽字符的过程中,您还会对 Unicode 有更多的了解。
为什么宽?
正如您在 Exploration 18 中看到的,特定字符值的含义取决于地区和字符集。例如,在一个语言环境中,您可以处理希腊语字符,而在另一个语言环境中,根据字符集,您可以处理西里尔语字符。你的程序需要知道区域设置和字符集,以便确定哪些字符是字母,哪些是标点,哪些是大写或小写,以及如何将大写字母转换成小写字母和将 ?? 转换成小写字母。
如果你的程序必须处理西里尔文和希腊文怎么办?如果这个程序需要同时处理它们呢?亚洲语言呢?中文不使用西方风格的字母表,而是使用成千上万种不同的表意文字。一些亚洲语言已经采用了一些中国的表意文字。典型的char
类型的实现达到了 256 个不同字符的极限,这远远不能满足国际需求。
换句话说,如果你想支持世界上大多数人和他们的语言,你不能使用普通的char
和string
类型。C++ 用宽字符解决了这个问题,它用几种类型来表示:wchar_t
、char16_t
和char32_t
。(与 C 对wchar_t
的定义不同,C++ 中的类型名是保留关键字和内置类型,而不是 typedefs。)其意图是wchar_t
是一个原生类型,可以表示不适合char
的字符。例如,对于较大的字符,程序可以支持亚洲字符集。char16_t
和char32_t
是 Unicode 类型。类型char8_t
也适用于 Unicode,但它是一种窄字符类型。探索从检查wchar_t
开始。
使用宽字符
在真正的 C++ 风格中,wchar_t
的大小和其他特征留给了实现。唯一的保证是wchar_t
至少和char
一样大,并且wchar_t
和一个内置的整数类型一样大。<cwchar>
头为这个内置类型声明了一个 typedef,std::wint_t
。在一些实现中,wchar_t
可能与char
相同,但是大多数桌面和工作站环境使用 16 或 32 位作为wchar_t
。
挖掘清单 26-2 并修改它,以揭示在您的 C++ 环境中wchar_t
和wint_t
的大小。wchar_t
中有多少位?_ _ _ _ _ _ _ _ _ _ _ _ _wint_t
中有多少?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 他们应该是同一个号码。char
中有多少位?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _****
****宽字符串对象使用std::wstring
类型(在<string>
中声明)。宽字符串是由宽字符组成的字符串。在所有其他方面,宽弦和窄弦的行为类似;它们具有相同的成员函数,并且您以相同的方式使用它们。例如,size()
成员函数返回字符串中的字符数,而不管每个字符的大小。
宽字符和字符串文字看起来像它们的窄对等物,除了它们以大写字母L
开始并且包含宽字符。在字符或字符串文字中表达宽字符的最佳方式是用\x
转义符指定字符的十六进制值(在探索 17 中介绍)。因此,您必须知道您的 C++ 环境使用的宽字符集,并且您必须知道该字符集中所需字符的数值。如果您的编辑器和编译器允许,您可以直接在宽字符文本中编写宽字符,但是您的源代码不能移植到其他环境中。您也可以在宽字符或字符串文字中写入窄字符,编译器会自动将窄字符转换为宽字符,如下所示:
wchar_t capital_a{'A'}; // the compiler automatically widens narrow characters
std::wstring ray{L"Ray"};
wchar_t pi{L'π'}; // if your tools let you type π as a character
wchar_t pi_unicode{L'\x03c0'}; // if wchar_t uses a Unicode encoding, such as UTF-32
std::wstring price{L"\x20ac" L"12345"}; // Unicode Euro symbol: €12345
请注意,在示例的最后一行,我是如何将字符串分成两部分的。回想一下 Exploration 17 中的内容,\x
转义开始一个转义序列,该序列通过十六进制(基数为 16)的值来指定一个字符。编译器收集尽可能多的字符来形成一个有效的十六进制数,即数字和字母A
到F
(大写或小写)。然后,它使用该数值作为单个字符的表示。如果最后一行是一个字符串,编译器会试图将整个字符串解释为\x
转义。这意味着编译器会认为字符值是十六进制值 20AC12345 16 。通过分隔字符串,编译器知道什么时候\x
转义结束,它编译字符值 20AC 16 ,后面是字符1
、2
、3
、4
和5
。就像窄字符串一样,编译器将相邻的宽字符串组装成一个宽字符串。(但是,不允许将窄弦和宽弦放在一起。使用全宽字符串或全窄字符串,而不是两者的混合。)
宽弦
你所知道的关于string
的一切也适用于wstring
。他们只是一个普通模板的实例,basic_string
。<string>
头声明string
是basic_string<char>
的 typedef,而wstring
是basic_string<wchar_t>
的 typedef。模板的魔力在于关注细节。
因为string
和wstring
的底层实现实际上是一个模板,任何时候你写一些使用字符串的实用程序代码,你都应该考虑把这些代码也做成一个模板。例如,假设您想要重写is_palindrome
函数(来自清单 22-5 ),以便它可以处理宽字符。与其把char
换成wchar_t
,不如把它变成一个函数模板。首先将支持函数重写为函数模板,将字符类型作为模板参数。重写 is_palindrome
的支持函数,使其适用于窄和宽的字符串和字符。清单 59-1 给出了我的解决方案。
import <locale>;
template<class Char>
auto const& ctype{ std::use_facet<std::ctype<Char>>(std::locale()) };
/** Test for non-letter.
* @param ch the character to test
* @return true if @p ch is not a letter
*/
template<class Char>
bool isletter(Char ch)
{
return ctype<Char>.is(std::ctype_base::alpha, ch);
}
/** Convert to lowercase.
* @param ch the character to test
* @return the character converted to lowercase
*/
template<class Char>
Char lowercase(Char ch)
{
return ctype<Char>.tolower(ch);
}
/** Compare two characters without regard to case. */
template<class Char>
bool same_char(Char a, Char b)
{
return lowercase(a) == lowercase(b);
}
Listing 59-1.Supporting Cast for the is_palindrome Function Template
下一个任务是重写is_palindrome
本身。模板实际上有三个模板参数,basic_string_view
有两个。第一个是人物类型,接下来的两个是我们此时不必关心的细节。重要的是,如果您想模板化您自己的处理字符串的函数,您应该处理所有三个模板参数。
然而,在开始之前,在将函数作为标准算法的参数时,您必须意识到一个小障碍:参数必须是真实的函数,而不是函数模板的名称。换句话说,如果你必须使用函数模板,比如lowercase
和non_letter
,你必须实例化模板并传递模板实例。当您将non_letter
和same_char
传递给remove_if
和equal
算法时,一定要传递正确的模板参数。如果Char
是字符类型的模板参数,使用non_letter<Char>
作为remove_if
的仿函数参数。
将 is_palindrome
函数重写为带有两个模板参数的函数模板。第一个模板参数是字符类型:称之为Char
。调用第二个模板参数Traits
。您必须对std::basic_string_view
模板使用这两个参数。清单 59-2 展示了我版本的is_palindrome
函数,它被转换成一个模板,因此它可以处理窄和宽的字符串。
import <ranges>;
import <string_view>;
/** Determine whether @p str is a palindrome.
* Only letter characters are tested. Spaces and punctuation don't count.
* Empty strings are not palindromes because that's just too easy.
* @param str the string to test
* @return true if @p str is the same forward and backward
*/
template<class Char, class Traits>
bool is_palindrome(std::basic_string_view<Char, Traits> str)
{
auto letters_only{ str | std::views::filter(isletter<Char>) };
auto reversed{ letters_only | std::ranges::views::reverse };
return std::equal(
std::ranges::begin(letters_only), std::ranges::end(letters_only),
std::ranges::begin(reversed), std::ranges::end(reversed),
same_char<Char>);
}
Listing 59-2.Changing is_palindrome to a Function Template
除了传递给basic_string_view
之外,is_palindrome
函数从不使用Traits
模板参数。如果您对该参数感到好奇,请查阅语言参考资料,但要注意它有点高级。
调用is_palindrome
很容易,因为编译器使用自动类型推断来确定您使用的是窄字符串还是宽字符串,并相应地实例化模板。因此,调用者根本不必为模板费心。
不再赘述,isletter
和lowercase
函数模板可以处理宽字符参数。这是因为区域设置是模板,在字符类型上参数化,就像字符串和 I/O 类模板一样。
然而,为了使用宽字符,您必须使用宽字符执行 I/O,这是下一节的主题。
宽字符输入输出
通过从std::wcin
开始读取,您可以从标准输入中读取宽字符。通过写入std::wcout
或std::wcerr
来写入宽字符。一旦您从流中读取或写入任何内容,相应的窄流和宽流的字符宽度是固定的,并且您不能更改它—您必须决定是使用窄字符还是宽字符,并在流的生存期内保持该选择。所以,一个程序必须使用cin
或wcin
,但不能两者都用。输出流也是如此。<iostream>
头声明了所有标准流的名称,窄流和宽流。<istream>
头定义了所有的输入流类和操作符;<ostream>
定义输出类和操作符。更准确的说,<istream>
和<ostream>
定义模板,字符类型是第一个模板参数。
<istream>
头定义了std::basic_istream
类模板,在字符类型上参数化。同一个头声明了两个 typedefs,如下所示:
using istream = basic_istream<char>;
using wistream = basic_istream<wchar_t>;
正如您所猜测的,<ostream>
头是相似的,定义了basic_ostream
类模板和ostream
和wostream
类型定义。
<fstream>
头遵循相同的模式— basic_ifstream
和basic_ofstream
是类模板,带有类型定义,如下所示:
using ifstream = basic_ifstream<char>;
using wifstream = basic_ifstream<wchar_t>;
using ofstream = basic_ofstream<char>;
using wofstream = basic_ofstream<wchar_t>;
从清单 22-5 重写主程序,测试带有宽字符 I/O 的 is_palindrome
函数模板。现代的桌面环境应该能够支持宽字符,但是您可能必须学习一些新的特性,以弄清楚如何让您的文本编辑器保存具有宽字符的文件。您可能还需要加载一些额外的字体。最有可能的情况是,您可以提供一个普通的窄文本文件作为输入,程序就会运行良好。如果你很难找到一个合适的输入文件,尝试一下回文文件,你可以下载本书中的其他例子。文件名表示字符集。例如,palindrome-utf8.txt
包含 UTF-8 输入。当读取一个宽流时,你必须确定你的 C++ 环境期望什么格式,并选择正确的文件。我的解决方案如清单 59-3 所示。
int main()
{
std::locale::global(std::locale{""});
std::wcin.imbue(std::locale{});
std::wcout.imbue(std::locale{});
std::wstring line{};
while (std::getline(std::wcin, line))
if (is_palindrome(std::wstring_view{line}))
std::wcout << line << L'\n';
}
Listing 59-3.The main Program for Testing is_palindrome
从文件中读取宽字符或将宽字符写入文件不同于读取或写入窄字符。所有文件 I/O 都要经过一个额外的字符转换步骤。C++ 总是将文件解释为一系列字节。当读取或写入窄字符时,将字节转换为窄字符是不可行的,但是当读取或写入宽字符时,C++ 库必须解释字节以形成宽字符。它通过累加一个或多个相邻的字节来形成每个宽字符。决定哪些字节是宽字符的元素以及如何组合字符的规则由多字节字符集的编码规则指定。
多字节字符集
多字节字符集起源于亚洲,那里对字符的需求超过了单字节字符集(如 ASCII)中可用的少数字符位。欧洲国家设法将他们的字母表放入 8 位字符集中,但是像中文、日文、韩文和越南文这样的语言需要更多的位来表示成千上万的表意文字、音节和原生字符。
亚洲语言的需求刺激了使用两个字节来编码一个字符的字符集的发展——因此有了通用术语双字节字符集 (DBCS),并概括为多字节字符集 (MBCS)。发明了许多 DBCSes,有时一个字符有多种编码。例如,在中文 Big 5 中,表意文字丁具有双字节值"\xA4\x42"
。在 EUC-韩国字符集(在韩国很流行)中,相同的表意文字有不同的编码:"\xEF\xCB"
。
典型的 DBCS 使用设置了最高有效位的字符(在一个 8 位字节中)来表示双字符。最高有效位清零的字符将取自单字节字符集(SBCS)。一些 DBC 委托特定的 SBCS;其他人则保持开放,因此 DBCS 和 SBCS 的不同组合会有不同的约定。在单个字符流中混合单字节和双字节字符对于表示混合亚洲和西方文本的字符流的常见用法是必要的。使用多字节字符比使用单字节字符更困难。例如,字符串的size()
函数不会告诉你一个字符串中有多少个字符。您必须检查字符串的每个字节,以了解字符的数量。对字符串进行索引更加困难,因为您必须小心不要索引到双字节字符的中间。
有时,单个字符流比简单地在一个特定的 SBCS 和一个特定的 DBCS 之间切换需要更多的灵活性。有时流必须混合多个双字节字符集。ISO 2022 标准就是允许在其他辅助字符集之间转换的字符集的一个例子。移位序列(也称为转义序列,不要与 C++ 反斜杠转义序列混淆)决定使用哪个字符集。例如,ISO 2022-JP 在日本被广泛使用,并允许在 ASCII、JIS X 0201(SBCS)和 JIS X 0208(DBCS)之间切换。每行文本以 ASCII 开始,shift 序列在字符串中间改变字符集。例如,换档顺序"\x1B$B"
切换到 JIS X 0208-1983。
在包含移位序列的文件或文本流中寻找任意位置显然是有问题的。一个必须在多字节文本流中查找的程序,除了流位置之外,还必须跟踪移位序列。如果不知道流中最近的移位序列,程序就无法知道用哪个字符集来解释后面的字符。
ISO 2022-JP 的许多变体允许附加的字符集。这里的重点不是提供关于亚洲字符集的教程,而是让您了解编写一个真正开放、通用和灵活的机制的复杂性,该机制可以支持世界上丰富多样的字符集和语言环境。这些以及类似的问题导致了 Unicode 项目的出现。
统一码
Unicode 试图通过将所有主要变体统一到一个大的、快乐的字符集来摆脱整个字符集的混乱。在很大程度上,Unicode 联合会取得了成功。Unicode 字符集已被采纳为 ISO 10646 的国际标准。然而,Unicode 项目不仅仅包括字符集;它还指定了大小写折叠、字符排序等规则。
Unicode 提供了 1114112 个可能的字符值(称为代码点)。到目前为止,Unicode Consortium 已经为字符分配了大约 100,000 个码位,因此还有很大的扩展空间。表示一百万个码位的最简单方法是使用 32 位整数,事实上,这是 Unicode 的一种常见编码。然而,这不是唯一的编码。Unicode 标准还定义了允许您使用一个或两个 16 位整数和一个或四个 8 位整数来表示代码点的编码。
表示 Unicode 码位的标准方式是 U+,后面跟一个至少有四位的十六进制数。因此,'\x41'
是 U+0041(拉丁文大写 A )的 C++ 编码,希腊文π的码位是 U+03C0。音乐的八分音符具有代码点 U+266A 或 U+1d 160;前一个码位在一组杂七杂八的符号里,刚好包括一个八分音符。后一个代码点是一组音乐符号的一部分,您将需要它来处理任何与音乐相关的字符。
UTF-32 是将码位存储为 32 位整数的编码名称。要在 C++ 中表示 UTF-32 码位,在字符前面加上U
(大写字母 U )。这样的字符文字有类型char32_t
。例如,要表示字母 A ,用U'A'
;对于小写希腊π,用U'\x03c0'
;对于音乐的八分音符,使用U'\x266a'
或U'\x1d160'
。对字符串文字做同样的操作,标准库为一个字符串char32_t
定义了类型std::u32string
。例如,要表示字符π ≈ 3.14,请使用以下公式:
std::u32string pi_approx_3_14{ U"\x03c0 \x2248 3.14" };
另一种常见的 Unicode 编码使用一到四个 8 位单元组成一个码位。西欧语言中的常见字符通常可以用一个字节表示,其他许多字符只需要两个字节。不常用的字符需要三个或四个。结果是一种支持所有 Unicode 码位的编码,并且几乎总是比其他编码消耗更少的内存。这个字符集被称为 UTF-8。UTF-8 字符以普通字符文字的方式书写,以u8
开头。UTF 8 字符串文字的类型是char8_t
。一架 UTF-8 弦有std:
式:u8string
。
表示一个希腊字母π只需要两个字节,但是与 UTF-32 中的两个低位字节的值不同:u8"\xcf\x80"
。第八个音符需要三或四个字节,同样使用不同于 UTF-32 的编码:u8"\
xe2\
x99\xaa"
或u8"\xf0\x9d\x85\xa0"
。
在程序中处理 UTF-8 的主要困难是,知道一个字符串中有多少代码点的唯一方法是扫描整个字符串。size()
成员函数返回字符串中存储单元的数量,但是每个码位需要一到四个存储单元。另一方面,UTF-8 的优点是,您可以在 UTF-8 字节流中查找任意位置,并知道该位置是否在多字节字符的中间,因为多字节字符总是有其最高有效位集。通过检查编码,您可以判断一个字节是多字节字符的第一个字节还是后面的字节。
UTF-8 是文件和网络传输的常用编码。它已经成为许多桌面环境、文字处理器(包括我用来写这本书的那个)、网页和其他日常应用的事实上的标准。
其他一些环境使用 UTF-16,它用一个或两个 16 位整数表示一个码位。UTF-16 字符文本的 C++ 类型是char16_t
,字符串类型是std::u16string
。用u
前缀(小写字母 u )写出这样一个字面值,比如u'\x03c0'
。
Unicode 的设计者将最常见的代码点保留在较低的 16 位区域(称为基本多语言平面,或 BMP)。当一个码位在 BMP 之外,也就是它的值超过 U+FFFF 时,它需要两个 UTF-16 的存储单元,被称为代理对。例如,丁需要两个 16 位存储单元:u"\
xD834\
xDD1E"
。
因此,您会遇到与 UTF-8 相同的问题,即一个存储单元不一定代表一个代码点,因此 UTF-16 作为内存中的表示法并不理想。但是大多数程序处理的绝大多数代码点都可以放在一个 UTF-16 存储单元中,所以 UTF-16 通常需要的内存是 UTF-32 的一半,而且在很多情况下,一个u16string
的size()
就是字符串中代码点的数量(虽然不扫描字符串你无法确定)。
一些程序员通过完全忽略代理对来解决使用 UTF-16 的困难。他们假设size()
确实返回了字符串中代码点的数量,所以只有当所有代码点都来自 BMP 时,他们的程序才能正常工作。这意味着你失去了访问古代文字,专门的字母和符号,以及不常用的表意文字。
对于外部表示,UTF 8 比 UTF-16 和 UTF-32 编码有优势,因为您不必处理字节序。Unicode 标准定义了一种机制,用于编码和显示 UTF-16 或 UTF-32 文本流的字节序,但这只是为您做了额外的工作。
Note
最高有效字节的位置称为“字节序”“大端”平台是最高有效字节优先的平台。“小端”平台将最低有效字节放在最前面。流行的英特尔 x86 平台是小端的。
通用字符名称
Unicode 在 C++ 标准中又一次正式出现。您可以使用字符的 Unicode 码位来指定字符。使用\uXXXX
或\UXXXXXXXX
,用十六进制码位替换XXXX
或XXXXXXXX
。与\x
转义不同,您必须在\u
中使用四个十六进制数字,或者在\U
中使用八个十六进制数字。这些字符结构被称为通用字符名。
因此,在字符串中对国际字符进行编码的更好方法是使用通用字符名。这有助于使你免受本地字符集的影响。另一方面,如果编译器无法将 Unicode 码位映射到本机字符,您就无法控制编译器的操作。因此,如果您的原生字符集是 ISO 8859-7(希腊语),下面的代码应该用值'\xf0'
初始化变量pi
,但是如果您的原生字符集是 ISO 8859-1 (Latin-1),编译器不能映射它,因此可能会给您一个空格或问号,或者编译器可能会拒绝编译它:
char pi{'\u03c0'};
还要注意\u
和\U
不是转义序列(不像\x
)。您可以在程序中的任何地方使用它们,而不仅仅是在字符或字符串中。使用 Unicode 字符名称可以让您在不知道编码细节的情况下使用 UTF-8 和 UTF-16 字符串。因此,对于希腊文小写π,更好的 UTF-8 字符串写法是u8"\
u03c0"
,编译器会存储编码后的字节"\xcf\x80"
。
如果你幸运的话,你将能够避免通用的字符名字。相反,您的工具将允许您直接编辑 Unicode 字符。该编辑器只是读取和写入通用字符名,而不是处理 Unicode 编码问题。因此,程序员编辑 WYSIWYG 国际文本,源代码保留了最大的可移植性。因为任何地方都允许通用字符名称,所以您也可以在注释中使用国际文本。如果你真的想玩得开心,试着在标识符名称中使用国际字母。不是所有的编译器都支持这个特性,尽管标准要求如此。因此,您应该编写一个声明
double π{3.14159265358979};
您的智能编辑器会在源文件中存储以下内容:
double \u03c0{3.14159265358979};
符合标准的编译器会接受它,并允许您使用π作为标识符。我不建议在标识符中使用扩展字符,除非你知道每个阅读你代码的人都在使用能够识别通用字符名的工具。否则,它们会使代码更难阅读、理解和维护。
你的编译器支持字符串中的通用字符名吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 你的编译器支持标识符中的通用字符名吗? _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
Unicode 困难
尽管 Unicode 有表面上的好处,但 C++ 支持仍然很少。虽然您可以编写 Unicode 字符文本和字符串文本,但是标准库没有提供有用的支持。试试这个练习:修改回文程序,用char32_t
代替wchar_t
。会发生什么?
没用的。Unicode 没有 I/O 流类。对于char8_t
、char16_t
或char32_t
,不存在isalnum
等的模板特化。尽管标准库提供了一些在 Unicode 字符串和wstring
之间进行转换的函数,但支持仅限于此。
如果你必须以任何有意义的方式使用国际字符,你需要一个第三方库。使用最广泛的库是 Unicode 国际组件(ICU)。请访问该书的网站获取最新链接。
第三部分的下一个也是最后一个主题是加深您对文本 I/O 的理解。****
六十、文本输入/输出
输入和输出有两种基本风格:文本和二进制。二进制 I/O 引入的微妙之处超出了本书的范围,因此本文中所有关于 I/O 的讨论都是面向文本的。这一探索展示了与文本 I/O 相关的各种主题。您已经看到了输入和输出操作符如何处理内置类型以及标准库类型(如果有意义的话)。您还看到了如何为自定义类型编写自己的 I/O 操作符。这一探索提供了一些关于文件模式、读取和写入字符串以及将值与字符串相互转换的更多细节。
文件模式
探索 14 简单介绍了文件流类ifstream
和ofstream
。基本行为是获取一个文件名并打开它。通过传递第二个参数,即文件模式,您可以获得更多的控制权。ifstream
的默认模式是std::ios_base::in
,打开文件进行输入。ofstream
的默认模式是std::ios_base::out | std::ios_base::trunc
。(|
运算符组合某些值,如模式。探索 68 将对此进行深入探讨。)模式out
打开文件进行输出。如果文件不存在,则创建该文件。trunc
模式意味着截断文件,因此您总是从一个空文件开始。如果您明确指定模式并省略trunc
,旧内容(如果有)将保留。因此,默认情况下,写入输出流会覆盖旧内容。如果要将流定位在旧内容的末尾,使用ate
模式(end 处的简称),将流的初始位置设置为现有文件内容的末尾。默认情况下,将流放在文件的开头。
另一种有用的输出模式是app
(是 append 的缩写),它使得每次写入都追加到文件中。也就是说,app
影响每次写入,而ate
只影响起始位置。在写入日志文件时,app
模式非常有用。
编写一个 debug()
函数,该函数将一个字符串作为参数,并将该字符串写入一个名为“debug . txt”的文件中。清单 60-1 显示了接口模块。
export module debug;
import <string_view>;
/** @brief Write a debug message to the file @c "debug.txt"
* @param msg The message to write
*/
export void debug(std::string_view msg);
Listing 60-1.Module That Declares a Trivial Debugging Function
将每条日志消息附加到文件中,用换行符结束每条消息。为了确保正确记录调试信息,即使程序崩溃,也要在每次调用debug()
函数时重新打开文件。清单 60-2 展示了我的实现模块。
module debug;
import <fstream>;
import <ostream>;
import <stdexcept>;
void debug(std::string_view str)
{
std::ofstream stream{"debug.txt", std::ios_base::out | std::ios_base::app};
if (not stream)
throw std::runtime_error("cannot open debug.txt");
stream.exceptions(std::ios_base::failbit);
stream << str << '\n';
stream.close();
}
Listing 60-2.Implementing the Debug Function
字符串流
除了文件流,C++ 还提供了字符串流。<sstream>
头定义了istringstream
和ostringstream
。一个字符串流读写一个std::string
对象。对于输入,将字符串作为参数提供给istringstream
构造器。对于输出,您可以提供一个字符串对象,但更常见的用法是让流为您创建和管理字符串。流追加到字符串,允许字符串根据需要增长。写完流之后,调用str()
成员函数来检索最终的字符串。
假设您必须从一个文件中读取代表汽车里程表读数和加满油箱所需燃油量的成对数字。该程序计算每加仑的英里数(或者每公里的升数,如果你喜欢的话)。文件格式很简单:每行都有里程表读数,后跟燃油量,在一行上,用空格隔开。
写程序。清单 60-3 展示了每加仑英里的方法。
import <iostream>;
int main()
{
double total_fuel{0.0};
double total_distance{0.0};
double prev_odometer{0.0};
double fuel{}, odometer{};
while (std::cin >> odometer >> fuel)
{
if (fuel != 0)
{
double distance{odometer - prev_odometer};
std::cout << distance / fuel << '\n';
total_fuel += fuel;
total_distance += distance;
prev_odometer = odometer;
}
}
if (total_fuel != 0)
std::cout << "Net MPG=" << total_distance / total_fuel << '\n';
}
Listing 60-3.Computing Miles per Gallon
清单 60-4 显示了相同的程序,但是计算的是升每公里。在接下来的探索中,我将使用英里每加仑。不使用这种方法的读者可以查阅《百升每公里》一书的随机文件。
import <iostream>;
int main()
{
double total_fuel{0.0};
double total_distance{0.0};
double prev_odometer{0.0};
double fuel{}, odometer{};
while (std::cin >> odometer >> fuel)
{
fuel *= 100.0;
double distance{odometer - prev_odometer};
if (distance != 0)
{
std::cout << fuel / distance << '\n';
total_fuel += fuel;
total_distance += distance;
prev_odometer = odometer;
}
}
if (total_distance != 0)
std::cout << "Net 100LPK=" << total_fuel / total_distance << '\n';
}
Listing 60-4.Computing Liters per Kilometer
如果用户不小心 忘记在文件的某一行记录燃料会怎样?
输入循环不知道也不关心行。在寻求满足每个输入请求时,它坚决跳过空白。因此,它读取后续线路的里程表读数作为燃油量。结果自然会不正确。
更好的解决方案是将每一行作为一个字符串读取,并从字符串中提取两个数字。如果字符串格式不正确,则发出一条错误消息并忽略该行。您通过调用std::getline
函数(在<string>
中声明)将一行文本读入到std::string
中。这个函数将一个输入流作为第一个参数,将一个string
对象作为第二个参数。它返回流,这意味着如果读取成功,它返回一个真值,如果读取失败,它返回一个假值,所以您可以使用对getline
的调用作为循环条件。
一旦有了字符串,打开一个istringstream
来读取字符串。像使用任何其他输入流一样使用字符串流。从字符串流中读取两个数字。如果字符串流不包含任何数字,则忽略该行。如果它只包含一个数字,则发出适当的错误消息。清单 60-5 呈现了新的程序。
import <iostream>;
import <sstream>;
import <string>;
int main()
{
double prev_odometer{0.0};
double total_fuel{0.0};
double total_distance{0.0};
std::string line{};
int linenum{0};
bool error{false};
while (std::getline(std::cin, line))
{
++linenum;
std::istringstream input{line};
double odometer{};
if (input >> odometer)
{
double fuel{};
if (not (input >> fuel))
{
std::cerr << "Missing fuel consumption on line " << linenum << '\n';
error = true;
}
else if (fuel != 0)
{
double distance{odometer - prev_odometer};
std::cout << distance / fuel << '\n';
total_fuel += fuel;
total_distance += distance;
prev_odometer = odometer;
}
}
}
if (total_fuel != 0)
{
std::cout << "Net MPG=" << total_distance / total_fuel;
if (error)
std::cout << " (estimated, due to input error)";
std::cout << '\n';
}
}
Listing 60-5.Rewriting the Miles-per-Gallon Program to Parse a String Stream
大多数文本文件格式都允许某种形式的注释或评论。文件格式已经允许一种形式的注释,作为程序实现的副作用。如何给输入文件添加注释?
在程序从一行中读取燃油量后,它会忽略字符串的其余部分。您可以在任何包含正确里程表和燃油数据的行中添加注释。但那是草率的副作用。更好的设计要求用户插入一个明确的注释标记。否则,程序可能会将错误的输入误解为有效的输入,后面跟着一个注释,例如意外插入了一个额外的空格,如下所示:
123 21 10.23
让我们修改文件格式。任何以井号(#
)开头的行都是注释。在读取注释字符时,程序跳过整行。将此功能添加到程序中。一个有用的函数是输入流的unget()
函数。从流中读取一个字符后,unget()
将该字符返回到流中,使后续的读取操作再次读取该字符。换句话说,读完一行,从该行中读出一个字符,如果是'#'
,则跳过该行。否则,调用unget()
并像以前一样继续。将您的结果与我的进行比较,如清单 60-6 所示。
import <iostream>;
import <sstream>;
import <string>;
int main()
{
double total_fuel{0.0};
double total_distance{0.0};
double prev_odometer{0.0};
std::string line{};
int linenum{0};
bool error{false};
while (std::getline(std::cin, line))
{
++linenum;
std::istringstream input{line};
char comment{};
if (input >> comment and comment != '#')
{
input.unget();
double odometer{};
if (input >> odometer)
{
double fuel{};
if (not (input >> fuel))
{
std::cerr << "Missing fuel consumption on line " << linenum << '\n';
error = true;
}
else if (fuel != 0)
{
double distance{odometer - prev_odometer};
std::cout << distance / fuel << '\n';
total_fuel += fuel;
total_distance += distance;
prev_odometer = odometer;
}
}
}
}
if (total_fuel != 0)
{
std::cout << "Net MPG=" << total_distance / total_fuel;
if (error)
std::cout << " (estimated, due to input error)";
std::cout << '\n';
}
}
Listing 60-6.Parsing Comments in the Miles-per-Gallon Data File
更复杂的是允许注释标记出现在一行的任何地方。注释从#
字符延伸到行尾。注释标记可以出现在一行的任何位置,但是如果该行包含任何数据,则它必须在注释标记之前包含两个有效数字。增强程序以允许注释标记出现在任何地方。考虑使用std::string
的find()
成员函数。它有多种形式,其中一种以字符作为参数,返回该字符在字符串中第一次出现的从零开始的索引。返回类型为std::string::size_type
。如果字符不在字符串中,find()
返回神奇常量std::string::npos
。
一旦找到注释标记,就可以通过调用erase()
删除注释,或者通过调用substr()
复制字符串的非注释部分。字符串成员函数使用从零开始的索引。子字符串表示为起始位置和受影响的字符数。通常,count 可以省略,表示字符串的其余部分。将你的解决方案与我的进行比较,如清单 60-7 所示。
import <iostream>;
import <sstream>;
import <string>;
int main()
{
double total_fuel{0.0};
double total_distance{0.0};
double prev_odometer{0.0};
std::string line{};
int linenum{0};
bool error{false};
while (std::getline(std::cin, line))
{
++linenum;
std::string::size_type comment{line.find('#')};
if (comment != std::string::npos)
line.erase(comment);
std::istringstream input{line};
double odometer{};
if (input >> odometer)
{
double fuel{};
if (not (input >> fuel))
{
std::cerr << "Missing fuel consumption on line " << linenum << '\n';
error = true;
}
else if (fuel != 0)
{
double distance{odometer - prev_odometer};
std::cout << distance / fuel << '\n';
total_fuel += fuel;
total_distance += distance;
prev_odometer = odometer;
}
}
}
if (total_fuel != 0)
{
std::cout << "Net MPG=" << total_distance / total_fuel;
if (error)
std::cout << " (estimated, due to input error)";
std::cout << '\n';
}
}
Listing 60-7.Allowing Comments Anywhere in the Miles-per-Gallon Data File
现在文件格式允许在每行上显式注释,您应该添加更多的错误检查,以确保每行只包含两个数字,仅此而已(删除注释后)。检查的一种方法是在读取两个数字后读取单个字符。如果读取成功,则该行包含错误的文本。添加错误检查以检测带有额外文本的行。将您的解决方案与我的解决方案进行比较,如清单 60-8 所示。
import <iostream>;
import <sstream>;
import <string>;
int main()
{
double total_fuel{0.0};
double total_distance{0.0};
double prev_odometer{0.0};
std::string line{};
int linenum{0};
bool error{false};
while (std::getline(std::cin, line))
{
++linenum;
std::string::size_type comment{line.find('#')};
if (comment != std::string::npos)
line.erase(comment);
std::istringstream input{line};
double odometer{};
if (input >> odometer)
{
double fuel{};
char check{};
if (not (input >> fuel))
{
std::cerr << "Missing fuel consumption on line " << linenum << '\n';
error = true;
}
else if (input >> check)
{
std::cerr << "Extra text on line " << linenum << '\n';
error = true;
}
else if (fuel != 0)
{
double distance{odometer - prev_odometer};
std::cout << distance / fuel << '\n';
total_fuel += fuel;
total_distance += distance;
prev_odometer = odometer;
}
}
}
if (total_fuel != 0)
{
std::cout << "Net MPG=" << total_distance / total_fuel;
if (error)
std::cout << " (estimated, due to input error)";
std::cout << '\n';
}
}
Listing 60-8.Adding Error-Checking for Each Line of Input
文本转换
让我戴上透视帽一会儿。看得出来你对 C++ 有很多未解的疑问;其中一个问题是“我怎样才能轻松地将一个数字转换成一个字符串,反之亦然呢?”
标准库提供了一些简单的函数:std::to_string()
接受一个整数并返回一个字符串表示。要将一个字符串转换成一个整数,可以根据所需的返回类型从几个函数中进行选择:std::stoi()
返回一个int
,而std::stod()
返回double
。
但是这些功能没有什么灵活性。您知道 I/O 流提供了很多灵活性和对格式的控制。您肯定会说,您可以创建使用合适的缺省值也同样容易使用的函数,而且在格式方面也提供了一些灵活性(比如浮点精度、填充字符等)。).
既然您已经知道了如何使用字符串流,接下来的路就很清楚了:使用istringstream
从字符串中读取数字,或者使用ostringstream
将数字写入字符串。唯一的任务是将功能包装在适当的函数中。更好的是使用模板。毕竟,读或写一个int
和读或写一个long
和其他的本质上是一样的。
清单 60-9 显示了from_string
函数模板,它只有一个模板参数T
——要转换的对象类型。该函数返回类型T
,并接受一个函数参数:一个要转换的字符串。
import <istream>;
import <sstream>;
import <stdexcept>;
import <string>;
template<class T>
requires
requires(T value, std::istream stream) {
stream >> value;
}
T from_string(std::string const& str)
{
std::istringstream in{str};
T result{};
if (in >> result)
return result;
else
throw std::runtime_error{str};
}
Listing 60-9.The from_string Function Extracts a Value from a String
T
可以是允许使用>>
操作符从输入流中读取的任何类型,包括您编写的任何自定义操作符和类型。
那么宣传的灵活性呢?再补充一些吧。如上所述,from_string
函数不检查值后面的文本。另外,它跳过了前导空白。修改函数取一个 bool
自变量: skipws
。如果为真,from_string
跳过前导空白并允许尾随空白。如果为 false,则不跳过前导空白,也不允许尾随空白。在这两种情况下,如果无效文本跟在值后面,它抛出runtime_error
。在清单 60-10 中将你的解决方案与我的进行比较。
import <istream>;
import <sstream>;
import <stdexcept>;
import <string>;
template<class T>
requires
requires(T value, std::istream stream) {
stream >> value;
}
T from_string(std::string const& str, bool skipws = true)
{
std::istringstream in{str};
if (not skipws)
in >> std::noskipws;
T result{};
char extra;
if (not (in >> result))
throw std::runtime_error{str};
else if (in >> extra)
throw std::runtime_error{str};
else
return result;
}
Listing 60-10.Enhancing the from_string Function
我加入了一个新的语言功能。函数参数skipws
后面是= true
,看起来像是赋值或者初始化。它让你用一个参数调用from_string
,就像以前一样,用true
作为第二个参数。如果你想知道,这就是文件流如何指定默认文件模式的。如果您决定声明默认参数值,则必须从参数列表中最右边的参数开始提供它们。我不经常使用默认参数,在探索 73 中,你会学到一些与默认参数和重载相关的微妙之处。现在,当它们有用的时候使用它们,但是要谨慎使用。
轮到你从头开始写一个函数了。编写 to_string
函数模板,它采用单个模板参数,并声明to_string
函数采用该类型的单个函数参数。该函数通过将其参数写入字符串流来将其转换为字符串,并返回结果字符串。将你的解决方案与我的进行比较,如清单 60-11 所示。
import <ostream>;
import <sstream>;
import <string>;
template<class T>
requires
requires(T value, std::ostream stream) {
stream << value;
}
std::string to_string(T const& obj)
{
std::ostringstream out{};
out << obj;
return out.str();
}
Listing 60-11.The to_string Function Converts a Value to a String
你能看出这些功能有什么特别的缺点吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _如果有,是什么?
毫无疑问,你可以看到许多问题,但特别是,我想指出的是,它们不适用于宽字符。浪费您花在理解宽字符上的所有精力是很可惜的,所以让我们为字符类型添加另一个模板参数。std::string
类模板有三个模板参数:字符类型、字符特征和一个管理字符串可能使用的堆内存的分配器对象。你不必知道这三种类型的任何细节;您只需要将它们传递给basic_string
类模板。basic_ostringstream
类模板采用前两个模板参数。
您第一次尝试实现to_string
可能看起来有点像清单 60-12 。
import <ostream>;
import <sstream>;
import <string>;
template<class T, class Char, class Traits, class Allocator>
requires
requires(T value, std::ostream stream) {
stream << value;
}
std::basic_string<Char, Traits, Allocator> to_string(T const& obj)
{
std::basic_ostringstream<Char, Traits> out{};
out << obj;
return out.str();
}
Listing 60-12.Rewriting to_string As a Template Function
这种实现是可行的。没错,但是很笨拙。试试看。试着写一个简单的测试程序,将一个整数转换成窄字符串,将同一个整数转换成宽字符串。如果你想不出怎么做,也不要气馁。这个练习演示了如果不小心的话,标准库中的模板是如何把你引入歧途的。看看我在清单 60-13 中的解决方案。
import <iostream>;
import to_string;
import from_string;
int main()
{
std::string str{
to_string<int, char, std::char_traits<char>, std::allocator<char>>(42)
};
int value{from_string<int>(str)};
std::cout << value << '\n';
}
Listing 60-13.Demonstrating the Use of to_string
你明白我的意思吗?您怎么知道第三和第四个模板参数是什么呢?别担心,我们能找到更好的解决办法。
另一种方法是不返回字符串,而是将它作为输出函数参数。然后编译器可以使用参数类型的演绎,你不必指定所有的模板参数。编写一个版本的 to_string
,它采用相同的模板参数,但也有两个函数参数:要转换的值和目标字符串。编写一个演示程序来展示这个函数使用起来有多简单。清单 60-14 展示了我的解决方案。
import <iostream>;
import <sstream>;
import <string>;
import from_string;
template<class T, class Char, class Traits, class Allocator>
requires
requires(T value, std::ostream stream) {
stream << value;
}
void to_string(T const& obj, std::basic_string<Char, Traits, Allocator>& result)
{
std::basic_ostringstream<Char, Traits> out{};
out << obj;
result = out.str();
}
int main()
{
std::string str{};
to_string(42, str);
int value(from_string<int>(str));
std::cout << value << '\n';
}
Listing 60-14.Passing the Destination String As an Argument to to_string
另一方面,如果你想在表达式中使用字符串,你仍然需要声明一个临时变量来保存字符串。
解决这个问题的另一种方法是将std::string
或std::wstring
指定为唯一的模板参数。编译器可以推断出要转换的对象的类型。basic_string
模板为其模板参数提供了成员类型,因此您可以使用它们来发现特征和分配器类型。to_string
函数返回字符串类型,并接受对象类型的参数。这两种类型都必须是模板参数。应该首先选择哪个参数?清单 60-15 展示了to_string
的最新版本,它现在接受两个模板参数:字符串类型和对象类型。
import <ostream>;
import <sstream>;
template<class String, class T>
requires
requires(T value, std::ostream stream) {
stream << value;
typename String::value_type;
typename String::traits_type;
}
String to_string(T const& obj)
{
std::basic_ostringstream<typename String::value_type,
typename String::traits_type> out{};
out << obj;
return out.str();
}
Listing 60-15.Improving the Calling Interface of to_string
还记得探险 57 里的typename
吗?编译器不知道String::value_type
命名了一个类型。一个basic_ostringstream
的特殊化可以声明它是任何东西。关键字typename
告诉编译器你知道这个名字是用于一个类型的。称这种形式为to_string
是直截了当的。
to_string<std::string>(42);
这种形式似乎在灵活性和易用性之间取得了最佳平衡。但是我们能增加更多的格式灵活性吗?我们应该增加宽度和填充字符吗?场调?十六进制还是八进制?如果to_string
以std::ios_base::fmtflags
为参数,调用者可以指定任何格式标志怎么办?默认应该是什么?清单 60-16 显示了当作者走极端时会发生什么。
import <iostream>;
import <sstream>;
template<class String, class T>
requires
requires(T value, std::ostream stream) {
stream << value;
typename String::value_type;
typename String::traits_type;
}
String to_string(T const& obj,
std::ios_base::fmtflags flags = std::ios_base::fmtflags{},
int width = 0,
char fill = ' ')
{
std::basic_ostringstream<typename String::value_type,
typename String::traits_type> out{};
out.flags(flags);
out.width(width);
out.fill(fill);
out << obj;
return out.str();
}
Listing 60-16.Making to_string Too Complicated
清单 60-17 展示了一些调用这种形式的to_string
的例子。
import <iostream>;
import <string>;
import to_string;
int main()
{
std::cout << to_string<std::string>(42, std::ios_base::hex) << '\n';
std::cout << to_string<std::string>(42.0, std::ios_base::scientific, 10) << '\n';
std::cout << to_string<std::string>(true, std::ios_base::boolalpha) << '\n';
}
Listing 60-17.Calling the Complicated Version of to_string
您应该会看到以下输出:
2a
4.200000e+01
true
这个版本的to_string
有太多的参数,不容易使用,而且它太冗长,无法实现任何类型的格式化。标准库为<format>
模块提供了一种完全不同的方法。您将需要一个语言参考来获得完整的解释,但基本思想是编写一个描述所需格式的格式化字符串,并传递与格式化字符串匹配的参数。格式字符串的工作方式类似于它们在 Python 中的工作方式。您可以传递任意数量的参数,并一次格式化所有参数。清单 60-18 演示了一次对format()
函数的调用如何格式化清单 60-17 的所有三个值。
import <format>;
import <iostream>;
int main()
{
std::cout << std::format("{0:x}\n{1:.10e}\n{2}\n", 42, 42.0, true);
}
Listing 60-18.Calling std::format
to Format a String
将宽字符串作为格式字符串传递,以产生宽字符串结果。也可以用输出迭代器作为第一个参数调用std::format_to()
,将格式化字符串写入迭代器,而不是构造一个字符串。这是格式化文本输出的更有效的方法。修改清单 60-17 以写入一个迭代器,该迭代器写入 std::cout
,从而消除对临时字符串的需要。因为format_to()
函数完成所有的格式化并生成字符,所以可以用std::ostreambuf_iterator<char>
代替ostream_iterator
。清单 60-19 展示了我的解决方案。
import <format>;
import <iostream>;
import <iterator>;
int main()
{
std::format_to(std::ostreambuf_iterator<char>(std::cout),
"{0:x}\n{1:.10e}\n{2}\n", 42, 42.0, true);
}
Listing 60-19.Calling std::format_to
to Format Output
第三部分到此结束。项目时间到了。
六十一、项目 3:货币类型
是时候进行另一个项目了。您将继续在项目 2 的fixed
类型的基础上进行构建,并结合您所学到的关于语言环境和 I/O 的知识。这次您的任务是编写一个currency
类型。该值存储为定点值。使用get_money
和put_money
操纵器格式化输入/输出。
确保可以将两个currency
量相加得到一个currency
值,减去两个currency
量得到currency
,将currency
乘以并除以一个整数或rational
值得到一个currency
结果,将两个currency
值相除得到一个rational
结果。
与任何项目一样,从小处着手,然后逐步增加功能。例如,从基本数据表示开始,然后添加 I/O 操作符。一次添加一个算术运算符。在实现特性之前,编写每个测试函数。
六十二、指针
很少有话题比指针更容易引起混淆,尤其是对于 C++ 新手来说。指针是必要的、强大的、多才多艺的,但它也可能是危险的,是许多 bug 的潜在原因,它们既是祸根也是福音。指针在标准库的许多特性背后努力工作,任何严肃的应用程序或库都不可避免地以某种方式使用指针。大多数应用程序员不直接使用指针,但是它们以你不能忽视的方式影响着整个 C++ 标准。
编程问题
在深入研究语法和语义之前,先考虑以下问题。现实生活中的 C++ 项目通常包含多个源文件,每个源文件导入多个模块。在工作时,您将多次编译和重新编译项目。每次,最好只重新编译那些已经改变的文件,或者导入一个接口已经改变的模块。不同的开发环境有不同的工具来决定重新编译哪些文件。IDE 通常自己做出这些决定;在其他环境中,一个单独的工具,如make
、jam
或scons
,检查项目中的文件并决定重新编译哪些文件。(我使用cmake
来编译和测试本书中的所有代码清单。)
在这一步和接下来的探索中要解决的问题是编写一个简单的工具来决定编译哪些文件并假装编译它们。(实际上调用外部程序超出了本书的范围,所以你不会学到如何编写一个完整的构建工具。)
基本思想很简单:要制作一个可执行程序,你必须把源文件编译成目标文件,然后把目标文件连接起来形成程序。据说可执行程序依赖于目标文件,而目标文件又依赖于源文件。其他术语将程序作为目标,将目标文件作为其依赖。反过来,一个目标文件也可以是一个目标,它有一个源文件和作为依赖项导入的模块接口文件。
如您所知,要将单个源文件编译成单个目标文件,编译器可能需要读取许多附加的模块文件。这些模块文件中的每一个都是目标文件的依赖项。因此,一个模块文件可以是许多目标文件的依赖项。用更专业的术语来说,目标和依赖关系形成了一个有向无环图(DAG),我称之为依赖图。
Note
一个循环图,例如 A 依赖于 B and B 依赖于 A,在现实世界中是一个坏主意,通常表明一个错误的或考虑不周的设计。为了简单起见,我将在本次和后续探索中忽略这一错误条件。
任何参与过大型项目的人都知道依赖图会变得非常复杂。有些模块文件可能是其他程序生成的,所以模块文件是目标,生成程序是依赖,生成程序是目标,有自己的依赖。
ide 和程序,如make
,分析依赖图并确定哪些目标必须首先构建,以确保每个目标的依赖关系都得到满足。因此,如果 A 依赖于 B and B 依赖于 C,make
必须首先构建 C(如果它是目标),然后是 B,最后是 A。make
用来找到构建目标的正确顺序的关键算法是一个拓扑排序。
拓扑排序不包括在许多计算机科学专业的典型算法课程中。算法也没有出现在很多教材里。然而,任何综合性的算法书都包括拓扑排序。
Note
关于拓扑排序的一篇好文章是算法简介,第三版。,作者 T. H. Cormen、C. E. Leiserson 和 R. L. Rivest(麻省理工学院出版社,2009 年)。我的解决方案实现了练习 22.4-5。
C++ 标准库不包括拓扑排序算法,因为它不是顺序算法。它在图上操作,C++ 库没有标准的图形类。
我们将通过编写一个伪make
程序来开始这一探索——也就是说,一个读取 makefile 的程序:一个描述一组目标及其依赖关系的文件,执行拓扑排序以找到构建目标的顺序,并以正确的构建顺序打印目标。为了在某种程度上简化程序,将输入限制为一个文本文件,该文件将依赖项声明为成对的字符串,一对字符串位于一行文本上。第一个字符串是目标的名称,第二个字符串是依赖项的名称。如果目标有多个依赖项,输入文件必须在多行中列出目标,每个依赖项一行。一个目标可以是另一个目标的依赖项。输入文件中各行的顺序并不重要。我们的目标是编写一个按顺序打印目标的程序,这样一个类似于make
的程序可以首先构建第一个目标,然后按顺序进行,这样所有的目标在作为依赖项被需要之前就被构建好了。
为了帮助澄清术语,我使用术语工件来表示可以是目标、依赖或者两者兼有的字符串。如果你已经知道了拓扑排序的算法,那么现在就开始实现这个程序吧。否则,继续阅读查看topological_sort
的一个实现。要表示依赖关系图,请使用集合映射。映射键是一个依赖项,值是将该键作为依赖项列出的一组目标。这似乎与你通常考虑的组织目标和依赖的方式完全不同,但是正如你在清单 62-1 中看到的,这使得拓扑排序很容易实现。因为topological_sort
函数是可重用的,所以它是一个模板函数,使用节点而不是工件、目标和依赖项。
export module topsort;
import <deque>;
import <ranges>;
import <stdexcept>;
// Helper function for topological_sort().
template<class Graph, class Nodes>
requires
std::ranges::range<Graph> and
requires {
typename Graph::value_type;
typename Graph::key_type;
}
void topsort_clean_graph(Graph& graph, Nodes& nodes)
{
for (auto iter{std::ranges::begin(graph)}; iter != std::ranges::end(graph);)
{
if (iter->second.empty())
{
nodes.push_back(iter->first);
graph.erase(iter++); // advance iterator before erase invalidates it
}
else
++iter;
}
}
/// Topological sort of a directed acyclic graph.
/// A graph is a map keyed by nodes, with sets of nodes as values.
/// Edges run from values to keys. The sorted list of nodes
/// is copied to an output iterator in reverse order.
/// @param graph The graph
/// @param sorted The output iterator
/// @throws std::runtime_error if the graph contains a cycle
/// @pre Graph::key_type == Graph::mapped_type::key_type
export template<class Graph, class OutIterator>
requires
std::ranges::range<Graph> and
requires {
typename Graph::value_type;
typename Graph::key_type;
}
and
std::output_iterator<OutIterator, typename Graph::key_type>
void topological_sort(Graph graph, OutIterator sorted)
{
std::deque<typename Graph::key_type> nodes{};
// Start with the set of nodes with no incoming edges.
topsort_clean_graph(graph, nodes);
while (not nodes.empty())
{
// Grab the first node to process, output it to sorted,
// and remove it from the graph.
auto n{nodes.front()};
nodes.pop_front();
*sorted = n;
++sorted;
// Erase n from the graph
for (auto& node : graph)
{
node.second.erase(n);
}
// After removing n, find any nodes that no longer
// have any incoming edges.
topsort_clean_graph(graph, nodes);
}
if (not graph.empty())
throw std::runtime_error("Dependency graph contains cycles");
}
Listing 62-1.Topological Sort of a Directed Acyclic Graph
现在已经有了topological_sort
函数,实现伪 make
程序来读取和解析输入,构建依赖图,调用topological_sort
,并打印排序后的结果。保持简单,将工件(目标和依赖)视为字符串。因此,依赖图是一个以std::string
为键类型、std::unordered_set<std::string>
为值类型的映射。(映射和集合不需要按字母顺序排列,所以使用无序容器。)将您的解决方案与清单 62-2 进行比较。
#include <cstdlib>
import <algorithm>;
import <iostream>;
import <iterator>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;
import topsort;
using artifact = std::string; ///< A target, dependency, or both
class dependency_graph
{
public:
using graph_type=std::unordered_map<artifact, std::unordered_set<artifact>>;
void store_dependency(artifact const& target, artifact const& dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
private:
graph_type graph_;
};
int main()
{
dependency_graph graph{};
std::string line{};
while (std::getline(std::cin, line))
{
std::string target{}, dependency{};
std::istringstream stream{line};
if (stream >> target >> dependency)
graph.store_dependency(target, dependency);
else if (not target.empty())
// Input line has a target with no dependency,
// so report an error.
std::cerr << "malformed input: target, " << target <<
", must be followed by a dependency name\n";
// else ignore blank lines
}
try {
// Get the artifacts in dependency order.
std::vector<artifact> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto const& artifact: sorted | std::ranges::views::reverse)
std::cout << artifact << '\n';
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 62-2.First Draft of the Pseudo-make Program
那么 Dag 和拓扑排序与这次探索的主题有什么关系呢?我以为你不会问。让我们通过使它更现实一点来构建一个稍微复杂一点的问题。
一个真正的make
程序必须记录更多关于工件的信息,尤其是最后一次修改的时间。如果任何依赖项比目标更新,目标也有一个要执行的操作列表。因此,对于表示工件,类比字符串更有意义。您可以为您的make
程序添加任何您需要的功能。
用std::filesystem::last_write_time()
查询文件的修改类型,在<filesystem>
中声明。返回的时间类型为std::filesystem::file_time_type
,可以用普通的比较运算符进行比较。忽略build()
动作,您可以定义如清单 62-3 所示的artifact
类型。
export module artifact;
import <filesystem>;
import <string>;
import <system_error>;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()} {}
artifact(std::string const& name)
: name_{name}, mod_time_{get_mod_time()}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
/// Builds a target.
/// After completing the actions (not yet implemented),
/// update the modification time.
void build();
/// Looks up the modification time of the artifact.
/// Returns file_time_type::min() if the artifact does not
/// exist (and therefore must be built) or if the time cannot
/// be obtained for any other reason.
file_time_type get_mod_time()
{
std::error_code ec;
auto time{ std::filesystem::last_write_time(name_, ec) };
if (ec)
return file_time_type::min();
else
return time;
}
private:
std::string name_;
file_time_type mod_time_;
};
Listing 62-3.New Definition of an Artifact
现在我们遇到了一个问题。在这个程序的第一稿中,两个字符串引用同一个工件是因为这两个字符串有相同的内容。名为“program”的目标与名为“program”的依赖项是同一个工件,因为它们的拼写相同。既然一个工件不仅仅是一个字符串,那么这个方案就失败了。当您构建一个目标并更新其修改时间时,您希望该工件的所有使用都得到更新。不知何故,工件名称的每次使用都必须与该名称的单个工件对象相关联。
有什么想法吗?以你目前对 C++ 的理解是可以做到的,但是你可能要停下来想一想。
需要提示吗?将所有工件存储在一个大向量中如何?然后制作一个依赖图,包含向量的索引,而不是工件名称。试试看。重写清单 62-2 中的程序,使用清单 62-3 中的新artifact
模块。当从输入文件中读取工件名称时,在所有工件的向量中查找该名称。如果神器是新的,就加到最后。在依赖图中存储向量索引。通过查找向量中的数字来打印最终列表。将您的解决方案与清单 62-4 进行比较。
Note
如果您担心线性查找的性能,那么恭喜您思维敏捷。不过,不要担心,因为在整个探索过程中,该程序将继续增长和发展,我们将在结束之前消除性能问题。
#include <cstdlib>
import <algorithm>;
import <iostream>;
import <iterator>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;
import artifact;
import topsort;
using artifact_index = std::size_t;
class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact_index,
std::unordered_set<artifact_index>>;
void store_dependency(artifact_index target, artifact_index dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact_index>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
private:
graph_type graph_;
};
std::vector<artifact> artifacts{};
artifact_index lookup_artifact(std::string const& name)
{
auto iter{ std::find_if(artifacts.begin(), artifacts.end(),
&name { return a.name() == name; })
};
if (iter != artifacts.end())
return iter - artifacts.begin();
// Artifact not found, so add it to the end.
artifacts.emplace_back(name);
return artifacts.size() - 1;
}
int main()
{
dependency_graph graph{};
std::string line{};
while (std::getline(std::cin, line))
{
std::string target_name{}, dependency_name{};
std::istringstream stream{line};
if (stream >> target_name >> dependency_name)
{
artifact_index target{lookup_artifact(target_name)};
artifact_index dependency{lookup_artifact(dependency_name)};
graph.store_dependency(target, dependency);
}
else if (not target_name.empty())
// Input line has a target with no dependency,
// so report an error.
std::cerr << "malformed input: target, " << target_name <<
", must be followed by a dependency name\n";
// else ignore blank lines
}
try {
// Get the artifact indices in dependency order.
std::vector<artifact_index> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto index: sorted | std::ranges::views::reverse)
std::cout << artifacts.at(index).name() << '\n';
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 62-4.Second Draft, After Adding Modification Times to Artifacts
嗯,这很有效,但是很难看。查找索引是草率的编程。更好的方法是直接在图中存储对artifact
对象的引用。啊,问题就在这里。不能在标准容器中存储引用。容器是用来存储对象的——真实的对象。容器必须能够复制和分配容器中的元素,但它不能通过引用做到这一点。复制引用实际上是复制它所引用的对象。引用不是程序可以操纵的一级实体。
如果 C++ 有一个类似于引用的语言特性,但允许您复制和赋值引用本身(而不是被引用的对象),这不是很好吗?假设我们正在发明 C++ 语言,我们必须添加这种语言功能。
解决方案
让我们设计一种新的语言特性来解决这个编程问题。这个新特性类似于引用,但允许与标准容器一起使用。让我们称这个特性为 flex-ref ,是“灵活引用”的缩写。
如果a
和b
都是引用类型int
的可变引用,则语句
a = b;
意味着a
的值改变,使得a
现在引用 b 所引用的同一个int
对象。将a
作为参数传递给函数会传递a
的值,所以如果函数给a
分配一个新值,这个变化对函数来说是局部的(就像其他函数参数一样)。然而,使用合适的操作符,该函数可以获得a
引用的int
对象,并读取或修改该int
。
你需要一种方法来获得引用的值,所以我们必须发明一个新的操作符。看看迭代器:给定一个迭代器,一元运算符*
返回迭代器引用的项。让我们对 flex-refs 使用相同的操作符。因此,下面打印出a
引用的int
值:
std::cout << *a;
按照*
操作符的精神,使用*
声明一个 flex-ref,就像使用&
作为引用一样。
int *a, *b;
声明容器时使用相同的语法。例如,声明一个引用类型int
的 flex-refs 向量。
std::vector<int*> vec;
vec.push_back(a);
b = vec.front();
剩下的工作就是提供一种方法让 flex-ref 引用一个对象。为此,让我们从普通参考文献中寻找灵感,并使用&
操作符。假设c
是int
类型,下面让a
指代c
:
a = &c;
正如您现在已经猜到的,flex-refs 是指针。变量a
和b
被称为“指向int
的指针”指针是一个真正的左值。它占用内存。存储在该存储器中的值是其他左值的地址。您可以自由地更改存储在该内存中的值,这样可以使指针指向不同的对象。
指针可以指向一个const
对象,也可以是一个const
指针,或者两者兼而有之。下面显示的是指向const int
的指针:
int const* p;
p = &c;
定义一个const
指针——也就是说,指针本身是const
,因此不能作为赋值的目标,但是被解引用的对象可以是目标。
int * const q{&c};
*q = 42;
像任何const
对象一样,你必须提供一个初始化器,并且你不能修改指针。但是,您可以修改指针指向的对象。
您可以定义对指针的引用,就像您可以定义对任何东西的引用一样(除了另一个引用)。
int const*&r{p};
像阅读任何其他声明一样阅读这个声明:首先找到声明符,r
。然后从内向外阅读宣言。向左看&
,告诉你r
是参考。右边是初始化器,{p}
;r
是对p
的引用(r
是对象p
的别称)。继续向左,你会看到*
,所以r
是对指针的引用。然后你看到了const
,所以你知道r
是一个指向const
的指针的引用。最后,int
告诉你r
是指向const int
的指针的引用。因此,初始化器是有效的,因为它的类型是指向int
的指针。
反过来呢?你能定义一个指向引用的指针吗?简单的回答就是不能。指向引用的指针和指向引用的引用一样没有意义。引用和指针必须引用或指向真实对象。当您在引用上使用&
操作符时,您将获得被引用对象的地址。
你可以定义一个指向指针的指针,或者指向指针的指针指向指针。只要记录下你的指针的确切类型。编译器确保您只分配正确类型的表达式,如下所示:
int x;
int *y;
int **z;
y = &x;
z = &y;
试试 z = &x
和 y = z
。会发生什么?
因为x
有类型int
,&x
有类型int*
;y
也有类型int*
,所以你可以将&x
赋给y
,但不能赋给z
,后者有类型int**
。类型必须匹配,所以也不能将z
赋给y
。
我花了很长时间才说到重点,但是现在你可以看到指针是如何帮助解决写依赖图的问题的。然而,在深入研究代码之前,让我们花点时间来澄清一些术语。
地址与指针
程序员注重细节。我们每天使用的编译器和其他工具迫使我们这样做。所以让我们绝对清楚地址和指针。
一个地址是一个内存位置。用 C++ 的说法,它是一个右值,所以你不能修改或分配一个地址。当一个程序获取一个对象的地址时(用&
操作符),结果在该对象的生命周期内是一个常量。像所有其他右值一样,C++ 中的地址也有类型,它必须是指针类型。
一个指针类型被更恰当地称为一个地址类型,因为该类型所代表的值的范围就是地址。尽管如此,术语指针类型更常见,因为指针对象有一个指针类型。
指针类型可以表示多级间接寻址—它可以表示指向指针的指针,或者指向指针的指针,等等。必须用星号声明每一级指针间接寻址。换句话说,int*
是“指向int
的指针”类型,int**
是“指向int
指针的指针”
指针是一个具有指针类型的左值。像任何对象一样,指针对象在内存中有一个位置,程序可以在其中存储一个值。该值的类型必须与指针的类型兼容;该值必须是正确类型的地址。
依赖图
现在让我们回到依赖图。图形可以存储指向工件的指针。每个外部文件对应程序中的一个artifact
对象。该工件在图中可以有许多指向它的节点。如果您更新该工件,所有指向该工件的节点都会看到更新。因此,当构建规则更新工件时,文件修改时间可能会改变。图中该工件的所有节点都会立即看到新时间,因为它们都指向一个对象。
剩下要弄清楚的就是这些藏物在哪里。为了简单起见,我推荐一个map
,以工件名称为关键字。映射的值是artifact
对象(不是指针)。获取图中工件的地址,以获得存储在图中的指针。说吧;不要等我。使用topsort
和artifact
模块,重写清单 62-4 来存储图中的artifact
对象和图中的artifact
指针。将您的解决方案与清单 62-5 进行比较。
#include <cstdlib>
import <algorithm>;
import <iostream>;
import <iterator>;
import <map>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;
import artifact;
import topsort;
class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact*,
std::unordered_set<artifact*>>;
void store_dependency(artifact* target, artifact* dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact*>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
private:
graph_type graph_;
};
std::map<std::string, artifact> artifacts{};
artifact* lookup_artifact(std::string const& name)
{
auto a( artifacts.find(name) );
if (a != artifacts.end())
return &a->second;
else
{
auto [iterator, inserted]{ artifacts.emplace(name, name) };
return &iterator->second;
}
}
int main()
{
dependency_graph graph{};
std::string line{};
while (std::getline(std::cin, line))
{
std::string target_name{}, dependency_name{};
std::istringstream stream{line};
if (stream >> target_name >> dependency_name)
{
artifact* target{lookup_artifact(target_name)};
artifact* dependency{lookup_artifact(dependency_name)};
graph.store_dependency(target, dependency);
}
else if (not target_name.empty())
// Input line has a target with no dependency, so report an error.
std::cerr << "malformed input: target, " << target_name <<
", must be followed by a dependency name\n";
// else ignore blank lines
}
try {
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 62-5.Storing Pointers in the Dependency Graph
总的来说,程序需要最小的改变,而改变大部分是简化的。随着程序变得越来越复杂(实际程序不可避免地会这样),指针的简单和优雅变得越来越明显。
一个新的特点是lookup_artifact
中一个奇怪的陈述:
auto [iterator, inserted]{ artifacts.emplace(name, name) };
这是一个定义了两个变量的声明,iterator
和inserted
。这些变量的初始值是从emplace()
函数返回的值,该函数返回一个std::pair
。编译器解包该对,用该对的成员first
初始化iterator
,用成员second
初始化inserted
。这种声明被称为结构化绑定。
artifact
对象存储在std::map
而不是unordered_map
中,因为当工件被添加到映射中时,指向工件的指针必须保持有效。在unordered_map
的情况下,它可能需要增加它的哈希表的大小,这可能意味着在内存中移动所有的工件对象。这将使依赖图所包含的所有指针失效。因为std::map
使用树结构,向树中添加节点不需要改变现有工件的存储位置。我们将重新考虑这个问题,但是首先让我们增强解析器。下一个探索着眼于如何使用正则表达式来解析 makefile。
六十三、正则表达式
所有现代语言都以某种方式支持正则表达式。一些脚本语言在语言语法中突出了它们。C++ 通过它的<regex>
模块为正则表达式提供了基本的支持。本文只探讨了 C++ 类和函数,并没有深入研究正则表达式语法。如果您需要复习正则表达式语法,许多印刷和在线资源都可以帮助您。C++ 支持多种正则表达式样式;默认是 ECMAScript 2019 中的RegeExp
对象(更好的说法是 JavaScript)。
用正则表达式解析
编写解析器通常需要解析器生成器工具,但是我们的依赖工具有一个非常简单的语言,我们可以使用正则表达式来解析输入。在一行文本中输入两个字符串很容易通过正则表达式匹配,或者说 regex :
^[ \t]*(\S+)[ \t]+(\S+)[ \t]*$
让我们使输入更像make
程序,并添加一个冒号将目标和它的依赖项分开:
^[ \t]*(\S+)[ \t]*:[ \t]*(\S+)[ \t]*$
我们还可以添加以#
字符开头的可选注释,或者允许该行为空白:
^[ \t]*(?:#.*|(\S+)[ \t]*:[ \t]*(\S+)[ \t]*(?:#.*)?)?$
构造一个std::regex
对象来编译正则表达式。如果正则表达式包含错误,构造器抛出std::regex_error
。记住反斜杠(\
)在字符串中有特殊的含义,在正则表达式中用作元字符,所以对于\\S
必须重复使用。在\t
中留下一个反斜杠,因为它是一个普通的制表符。regex_match()
函数根据正则表达式测试一个字符串,并且根据您传递的参数,也可以返回捕获组。复制清单 62-5 并将解析器更改为使用正则表达式会产生清单 63-1 。
#include <cstdlib>
import <iostream>;
import <iterator>;
import <map>;
import <ranges>;
import <regex>;
import <sstream>;
import <stdexcept>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import <vector>;
import artifact;
import topsort;
class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact*,
std::unordered_set<artifact*>>;
void store_dependency(artifact* target, artifact* dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact*>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
artifact* lookup_artifact(std::string const& name)
{
auto a( artifacts_.find(name) );
if (a != artifacts_.end())
return &a->second;
else
{
auto [iterator, inserted]{ artifacts_.emplace(name, name) };
return &iterator->second;
}
}
private:
graph_type graph_;
std::map<std::string, artifact> artifacts_;
};
int main()
{
dependency_graph graph{};
static const std::regex regex{
"^[ \t]*(?:#.*|(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*(?:#.*)?)?$"
};
std::string line{};
std::size_t line_number{};
while (std::getline(std::cin, line))
{
++line_number;
std::smatch match;
if (std::regex_match(line, match, regex))
{
// Skip comments and blank lines.
if (match[1].matched) {
auto target{graph.lookup_artifact(match[1].str())};
auto dependency{graph.lookup_artifact(match[2].str())};
graph.store_dependency(target, dependency);
}
}
else
// Input line cannot be parsed.
std::cerr << "line " << line_number << ": parse error\n";
}
try {
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 63-1.Parsing with a Regular Expression
现在我们的解析器有了更多的功能,我们可以开始添加特性了。让我们添加变量。变量定义的形式为variable=value
。变量引用的形式为$(variable)
,并被替换为variable
的值。两个相邻的美元符号($$
)被一个美元符号代替。未知变量扩展为空字符串。替换所有变量扩展后,重新扫描字符串以查看是否有更多的扩展要进行。因此,$$(var$(n))
首先通过将$$
转换为$
并查找变量n
来扩展。假设n
具有值42
。在用$
替换$$
和用n
的值替换$(n)
之后,通过查找var42
来扩展结果字符串$(var42)
。
在开始重写解析器之前,让我们将dependency_graph
类移到它自己的模块中。确定它必须导入哪些模块,并编写一个导出dependency_graph
的depgraph
模块。更改store_dependency
,使其接受两个字符串作为参数,并在本地处理所有工件指针,这样main()
就不需要关心工件实际上是如何被管理的。编写depgraph
模块。在清单 63-2 中将你的与我的进行比较。
export module depgraph;
import <iterator>;
import <map>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import artifact;
import topsort;
export class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact*,
std::unordered_set<artifact*>>;
void store_dependency(std::string const& target_name,
std::string const& dependency_name)
{
auto target{ lookup_artifact(target_name) };
auto dependency{ lookup_artifact(dependency_name) };
store_dependency(target, dependency);
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact*>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
artifact* lookup_artifact(std::string const& name)
{
auto a{ artifacts_.find(name) };
if (a != artifacts_.end())
return &a->second;
else
{
auto [iterator, inserted]{ artifacts_.emplace(name, name) };
return &iterator->second;
}
}
private:
void store_dependency(artifact* target, artifact* dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type graph_;
std::map<std::string, artifact> artifacts_;
};
Listing 63-2.The depgraph Module
您将需要存储和检索变量。编写一个variables
模块,它导出函数来定义、查找和扩展变量。对于扩展变量,我们将使用一个regex_iterator
来查找输入字符串中的所有变量。迭代器值是一个smatch
对象。勇敢一点,试着编写variables
模块,或者看看清单 63-3 。
export module variables;
import <ranges>;
import <regex>;
import <string>;
import <unordered_map>;
std::unordered_map<std::string, std::string> variables;
export void define_variable(std::string const& name, std::string const& value)
{
variables[name] = value;
}
std::string const empty_string;
export std::string const& lookup_variable(std::string const& name)
{
if (auto var = variables.find(name); var == variables.end())
return empty_string;
else
return var->second;
}
export std::string expand_variables(std::string const& input)
{
static const std::regex regex{ "\\$(?:\\$|\\(([\\w.-_]+)\\))" };
std::string result{};
auto prefix_begin{ input.begin() };
auto begin{ std::sregex_iterator{input.begin(), input.end(), regex} };
auto end{ std::sregex_iterator{} };
bool matched{false};
using subrange = std::ranges::subrange<std::sregex_iterator>;
for (auto const& match: subrange(begin, end)){
// Copy the string prior to the match
result.append(prefix_begin, match[0].first);
prefix_begin = match[0].second;
if (match[1].matched)
{
result += lookup_variable(match[1].str());
matched = true;
}
else
result += '$'; // no variable, so the regex matched $$
}
// copy rest of unmatched string
result.append(prefix_begin, input.end());
if (not matched)
return result;
// try matching again.
return expand_variables(result);
}
Listing 63-3.The variables Module
有了新的depgraph
和variables
模块,是时候重新审视一下main()
中的解析器了。你可以自己做这件事。在解析了目标和依赖名之后,展开其中的变量,并将它们添加到依赖图中。在清单 63-4 中将你的程序与我的程序进行比较。
#include <cstdlib>
import <iostream>;
import <iterator>;
import <ranges>;
import <regex>;
import <string>;
import <vector>;
import artifact;
import depgraph;
import variables;
int main()
{
dependency_graph graph{};
static const std::regex regex{
"^[ \t]*(?:#.*|[ \t]*(\\S+)[ \t]*=[ \t]*(.*)|(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*(?:#.*)?)?$"
};
std::string line{};
std::size_t line_number{};
while (std::getline(std::cin, line))
{
++line_number;
std::smatch match;
if (std::regex_match(line, match, regex))
{
if (match[1].matched)
// variable definition
define_variable(match[1].str(), match[2].str());
else if (match[3].matched) {
// target: dependency
auto target{expand_variables(match[3].str())};
auto dependency{expand_variables(match[4].str())};
graph.store_dependency(target, dependency);
}
// else comment or blank line
}
else
// Input line cannot be parsed.
std::cerr << "line " << line_number << ": parse error\n";
}
try {
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 63-4.New Program Using depgraph and variables Modules
下一步是允许每个目标变量。也就是说,如果输入以目标名称和冒号开头,但后面是变量定义而不是依赖项名称,则变量定义仅适用于该目标,例如:
NUM=1
target$(NUM) : SRC=1
target$(NUM) : source$(SRC)
target2 : source$(NUM)
target2 : source$(SRC)
NUM
变量是全局的,所以target2
依赖于source1
。SRC
变量只适用于target1
,所以target1
依赖于source1
。另一方面,最后一行说target2
依赖于source
,而不是source2
,因为target2
没有SRC
变量,未知变量扩展为空字符串。
让我们从给每个工件添加一个局部变量映射开始。稍后,我们将细化实现以区分目标和依赖项,以便只有目标才有变量映射。让我们从重温variables
模块开始。因为我们现在有了局部变量和全局变量,所以编写一个可以满足这两个目的的variables
类似乎是个好主意。但是有一个问题。
在查找变量名时,需要在特定于目标的映射和全局映射中查找。所以查找要看是什么样的映射。修改 variables 模块,定义一个实现映射逻辑的基类,有两个局部和全局变量的派生类,不同之处仅在于它们的查找函数。在清单 63-5 中将你的模块与我的进行比较。
export module variables;
import <ranges>;
import <regex>;
import <string>;
import <unordered_map>;
class base
{
public:
virtual ~base() = default;
virtual std::string const& lookup(std::string const& name) const = 0;
void define(std::string const& name, std::string const& value)
{
map_[name] = value;
}
std::string expand(std::string const& input)
const
{
static const std::regex regex{ "\\$(?:\\$|\\(([\\w.-_]+)\\))" };
std::string result{};
auto prefix_begin{ input.begin() };
auto begin{ std::sregex_iterator{input.begin(), input.end(), regex} };
auto end{ std::sregex_iterator{} };
bool matched{false};
using subrange = std::ranges::subrange<std::sregex_iterator>;
for (auto const& match: subrange(begin, end)){
// Copy the string prior to the match
result.append(prefix_begin, match[0].first);
prefix_begin = match[0].second;
if (match[1].matched)
{
result += lookup(match[1].str());
matched = true;
}
else
result += '$'; // no variable, so the regex matched $$
}
// copy rest of unmatched string
result.append(prefix_begin, input.end());
if (not matched)
return result;
// try matching again.
return expand(result);
}
protected:
base() = default;
static const std::string empty_string;
std::unordered_map<std::string, std::string> map_;
};
const std::string base::empty_string;
class global : public base
{
public:
std::string const& lookup(std::string const& name)
const override
{
if (auto var = map_.find(name); var == map_.end())
return empty_string;
else
return var->second;
}
};
// Global variables
export global global_variables;
// Target-specific variables
export class variables : public base
{
public:
std::string const& lookup(std::string const& name)
const override
{
if (auto var = map_.find(name); var == map_.end())
return global_variables.lookup(name);
else
return var->second;
}
};
Listing 63-5.Adding Local and Global Maps to the variables Module
向 artifact
添加一个 variables
数据成员(来自清单 62-3 )。为了隐藏variables
对象,让artifact
类提供它自己的define()
和expand()
成员函数来转发给variables
成员。将您的模块与清单 63-6 中的模块进行比较。
export module artifact;
import <filesystem>;
import <string>;
import <system_error>;
import variables;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact(std::string const& name)
: name_{name}, mod_time_{get_mod_time()}, vars_{}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
/// Builds a target.
/// After completing the actions (not yet implemented),
/// update the modification time.
void build();
/// Looks up the modification time of the artifact.
/// Returns file_time_type::min() if the artifact does not
/// exist (and therefore must be built) or if the time cannot
/// be obtained for any other reason.
file_time_type get_mod_time()
{
std::error_code ec;
auto time{ std::filesystem::last_write_time(name_, ec) };
if (ec)
return file_time_type::min();
else
return time;
}
void define(std::string const& name, std::string const& value)
{
vars_.define(name, value);
}
std::string expand(std::string const& input)
const
{
return vars_.expand(input);
}
private:
std::string name_;
file_time_type mod_time_;
variables vars_;
};
Listing 63-6.The New artifact Module
现在您已经准备好更新解析器了。正则表达式越来越难看了,这是您应该开始考虑其他解析器选项的时候了。标准 C++ 没什么帮助,但是可以看看解析器生成器或者第三方解析器库。坚持使用标准 C++,我们只是让正则表达式稍微复杂一点。但是我们可以利用编译器自动连接相邻字符串的优势,使它更容易阅读。将正则表达式分成关键部分,并将每个部分放入自己的字符串中。在字符串之间添加适当的空格以增强可读性。
编写一个解析器模块,导出一个解析函数。该函数应该接受一个istream
和一个dependency_graph
参数,两者都通过引用传递。你的和我的清单 63-7 相似吗?
export module parser;
import <iostream>;
import <regex>;
import <string>;
import artifact;
import depgraph;
const std::regex regex{
"^[ \t]*"
"(?:"
"#.*" "|"
"[ \t]*(\\S+)[ \t]*=[ \t]*(.*)" "|"
"(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*=[ \t]*(.*)" "|"
"(\\S+)[ \t]*:[ \t]*(\\S+)[ \t]*(?:#.*)?"
")?$"
};
export void parse(std::istream& stream, dependency_graph& graph)
{
bool okay{true};
std::string line{};
std::size_t line_number{};
while (std::getline(stream, line))
{
++line_number;
std::smatch match;
if (std::regex_match(line, match, regex))
{
if (match[1].matched)
// var=value
global_variables.define(match[1].str(), match[2].str());
else if (match[3].matched) {
// target: var=value
auto target_name{ global_variables.expand(match[3].str()) };
auto target{graph.lookup_artifact(target_name)};
target->define(match[4].str(), target->expand(match[5].str()));
}
else if (match[6].matched) {
// target: dependency
auto target_name{ global_variables.expand(match[6].str()) };
auto target{target_name};
auto dependency{
graph.lookup_artifact(target)->expand(match[7].str())
};
graph.store_dependency(target, dependency);
}
// else comment or blank line
}
else
{
// Input line cannot be parsed.
std::cerr << "line " << line_number << ": parse error\n";
okay = false;
// Keep going in case there are more errors.
}
}
if (not okay)
throw std::runtime_error("Cannot continue due to parse errors");
}
Listing 63-7.The parser Module
现在主程序更简单了。编写新的主程序。我对它的看法在清单 63-8 中。
#include <cstdlib>
import <iostream>;
import <iterator>;
import <ranges>;
import <stdexcept>;
import <vector>;
import artifact;
import depgraph;
import parser;
int main()
{
try {
dependency_graph graph{};
parse(std::cin, graph);
// Get the sorted artifacts.
std::vector<artifact*> sorted{};
graph.sort(std::back_inserter(sorted));
// Print in build order, which is reverse of dependency order.
for (auto artifact : sorted | std::ranges::views::reverse)
{
std::cout << artifact->name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 63-8.New Program Using the parser Module
在进入伪 make 程序的下一步之前,是时候深入研究一下std::move()
的魔力了,我在《探索 40 中介绍了它,但没有做任何解释。接下来的探索最后解释一下std::move()
及其在 C++ 编程中的重要性。
六十四、移动带有右值引用的数据
在 Exploration 40 中,我介绍了std::move()
,但没有解释它真正做了什么或如何工作。不知何故,它将数据(如字符串)从一个变量移动到另一个变量,而不是复制字符串内容,但您一定想知道它是如何创造奇迹的。这个函数本身非常简单。这个简单实现背后的概念更加复杂,这就是为什么我一直等到现在才介绍其中的复杂性和微妙之处。
复制与移动
如您所知,std::vector 类型存储一个值数组。随着向 vector 添加更多的值,它可能需要分配一个新的数组来保存更多的值。当这种情况发生时,它必须将旧数组复制到新数组中。如果值是大对象,这可能是一个昂贵、耗时的操作。但是,如果值类型允许,vector 将移动这些值,而不是复制它们。想象一下,如果你买了一本战争与和平,你必须复制它的内容才能把书带回家,而不是把它从书店搬到家里。
诀窍是编译器知道什么时候可以移动数据,什么时候必须复制数据。例如,普通的赋值需要创建一个赋值源的副本,所以赋值的目标最终得到了源的精确副本。将参数传递给函数也需要制作参数的副本,除非该参数被声明为引用类型。
但有时,函数参数是临时的,编译器知道它是临时的。编译器知道它不必从临时对象中复制数据。临时的即将被摧毁;它不需要保留自己的数据,因此可以将数据移动到目标位置,而无需拷贝。
为了帮助您直观地看到对象被复制时会发生什么,让我们创建一个新的类来包装std::string
并向您显示它的复制构造器和赋值操作符何时被调用。然后创建一个小程序,从std::cin
中读取字符串,并将它们添加到一个向量中。编写程序,将你的程序与清单 64-1 中我的程序进行比较。
import <iostream>;
import <string>;
import <vector>;
class mystring : public std::string
{
public:
mystring() : std::string{} { std::cout << "mystring()\n"; }
mystring(mystring const& copy) : std::string{copy} {
std::cout << "mystring copy(\"" << *this << "\")\n";
}
};
std::vector<mystring> read_data()
{
std::vector<mystring> strings{};
mystring line{};
while (std::getline(std::cin, line))
strings.push_back(line);
return strings;
}
int main()
{
std::vector<mystring> strings{};
strings = read_data();
}
Listing 64-1.Exposing How Strings Are Copied
试着用几行输入运行程序。每个字符串被复制多少次? ______ 程序将line
复制到push_back()
中的矢量中。当编译器将strings
变量返回给调用者时,它知道自己不必复制向量。它可以移动它。因此,你不会得到任何额外的副本。
怎样才能减少字符串被复制的次数?line
变量存储临时数据。程序没有理由在调用push_back()
后保留line
中的值。所以我们知道将字符串内容从line
移到data
是安全的。调用std::move()
告诉编译器可以将字符串移入向量。您还必须向mystring
添加一个移动构造器。参见清单 64-2 中的新程序。现在每个字符串被复制了多少次? ______
import <iostream>;
import <string>;
import <utility>;
import <vector>;
class mystring : public std::string
{
public:
mystring() : std::string{} { std::cout << "mystring()\n"; }
mystring(mystring const& copy) : std::string{copy} {
std::cout << "mystring copy(\"" << *this << "\")\n";
}
mystring(mystring&& move) noexcept
: std::string{std::move(move)} {
std::cout << "mystring move(\"" << *this << "\")\n";
}
};
std::vector<mystring> read_data()
{
std::vector<mystring> strings{};
mystring line{};
while (std::getline(std::cin, line))
strings.emplace_back(std::move(line));
return strings;
}
int main()
{
std::vector<mystring> strings;
strings = read_data();
}
Listing 64-2.Moving Strings Instead of Copying Them
新的构造器用一个双&符号(&&
)声明它的参数。它看起来有点像参考文献。注意参数不是const
。这是因为从一个对象移动数据必然会修改该对象。最后,回想一下 Exploration 48 中的noexcept
说明符告诉编译器,构造器不能抛出异常。还要注意的是,mystring
构造器调用std::move()
将其参数移入std::string
构造器。您必须为任何命名的对象调用std::move()
,即使该对象是一个特殊的&&
引用。
确切的输出取决于你的库的实现,但是大多数从 vector 的少量内存开始,并且开始时增长缓慢,以避免浪费内存。因此,只需添加几个字符串,就可以揭示 vector 在重新分配数组时是如何移动或复制字符串的。表 64-1 显示了当我提供三路输入时,列表 64-1 和列表 64-2 的输出。
表 64-1。
比较清单 64-1 和清单 64-2 的输出
|清单 60-1
|
清单 60-2
|
| --- | --- |
| mystring()``mystring copy("one")``mystring copy("two")``mystring copy("one")``mystring copy("three")``mystring copy("two")``mystring copy("one")
| mystring()``mystring move("one")``mystring move("two")``mystring move("one")``mystring move("three")``mystring move("two")``mystring move("one")
|
本文的其余部分解释了 C++ 如何实现这种移动功能。
左值、右值等等
回想一下 Exploration 21 中的内容,表达式分为两类:左值或右值。非正式地,左值可以出现在赋值的左边,右值出现在右边。至少这是“l”和“r”名字的由来。更具体地说,左值有标识符,存储在内存中的某个地方。右值不一定要有这两者,尽管它们可能有。向函数传递参数类似于赋值:函数参数扮演左值的角色,参数是右值。
区分左值和右值的一个关键方法是你可以获取左值的地址(使用操作符&
)。编译器不让你获取一个右值的地址,这是有意义的。42 的地址是什么?
编译器会在必要时自动将左值转换为右值,比如将左值作为参数传递给函数,或者将左值用作赋值的右侧。编译器将右值转换为左值的唯一情况是左值的类型是对const
的引用。例如,一个将其参数声明为std::string const&
的函数可以将一个右值std::string
作为参数,编译器将该右值转换为左值。但是除了那种情况,你不能把右值变成左值。
当考虑对象的生存期时,左值和右值之间的区别很重要。您知道变量的作用域决定了它的生存期,所以任何有名称的左值(例如,变量或函数参数)的生存期都由名称的作用域决定。另一方面,右值是暂时的。除非您将名称绑定到该右值(记住名称的类型必须是对const
的引用),否则编译器会尽快销毁临时对象。
例如,在下面的表达式中,创建了两个临时的std::string
对象,然后传递给operator+
来连接字符串。operator+
函数将其std::string const&
参数绑定到相应的参数,从而保证这些参数至少在函数返回之前有效。operator+
函数返回一个新的临时std::string
,然后打印到std::cout
:
std::cout << std::string("concat") + std::string("enate");
一旦语句执行完毕,临时的std::string
对象就可以被销毁。std::move()
函数可以让你区分对象的生命周期和它包含的数据,比如组成一个字符串的字符或者一个向量的元素。该函数接受一个左值(其生存期由作用域决定),并将其转换为右值,因此内容可以被视为临时的。因此,在清单 64-1 中,line
的寿命由作用域决定。但是在清单 64-2 中,通过调用std::move()
,您说将line
的字符串内容视为临时是安全的。
因为std::move()
将左值转换为右值,所以返回类型(使用双&符号)被称为右值引用。mystring
move 构造器的参数也使用双&符号,所以它们的类型是右值引用。单个&符号引用类型称为左值引用,以便与右值引用明确区分开来。
术语上有些混乱,带有右值引用类型的表达式既属于右值表达式范畴,也属于左值范畴。为了减少混淆,给这种表达式起了一个特殊的名字: xvalue ,表示“过期值”。也就是说,表达式仍然是一个左值,可以出现在赋值的左边,但它也是一个右值,因为它接近了生命周期的末尾,所以您可以随意窃取它的内容。
不是 xvalues 的右值有不同的名字:纯右值,或 prvalue 。纯右值是诸如数值、算术表达式、函数调用(如果返回类型不是引用类型)等表达式。完全缺乏对称性,就没有纯粹的左值。取而代之的是,左值这一术语用于表示不是 x 值的左值类。新术语广义左值,或 glvalue ,适用于所有左值和 x 值。图 64-1 描述了各种表达式类别。
图 64-1。
表达式类别
所以结果是std::move()
实际上是一个微不足道的函数。它将左值引用作为参数,并将其转换为右值引用。这种差异对于编译器处理表达式的方式很重要,但是std::move()
不生成任何代码,对性能没有影响。
总结一下:
-
调用返回左值引用类型的函数会返回左值类别的表达式。
-
调用返回右值引用类型的函数会返回类别 xvalue 的表达式。
-
调用返回非引用类型的函数会返回类别 prvalue 的表达式。
-
编译器将右值(xvalue 或 prvalue)参数与右值引用类型的函数参数进行匹配。它将左值参数与左值引用相匹配。
-
命名对象具有类别左值,即使该对象的类型是右值引用。
-
声明一个右值引用类型的参数(使用双&符号)以从参数中移动数据。
-
调用
std::move()
作为赋值的源,或者当您想要从左值移动数据时,将参数传递给函数。这将左值引用转换为右值引用。
实施移动
为mystring
类实现一个构造器很容易,因为它只是将其参数移动到基类的 move 构造器中。但是像std::string
或std::vector
这样的类是如何实现移动功能的呢?回到清单 63-6 中的artifact
类。应该可以移动一个artifact
。想想你会如何去移动一件艺术品。到底需要移动什么?怎么做?
为 artifact
类写一个 move 构造器。然后将您的解决方案与清单 64-3 中我的解决方案进行比较。
export module artifact;
import <filesystem>;
import <string>;
import <system_error>;
import variables;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact(std::string name)
: name_{std::move(name)}, mod_time_{get_mod_time()}, vars_{}
{}
artifact(artifact&& src) noexcept
: name_{std::move(src.name_)},
mod_time_{std::move(src.mod_time_)},
vars_{std::move(src.vars_)}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
file_time_type get_mod_time();
void define(std::string const& name, std::string const& value);
std::string expand(std::string const& input) const;
private:
std::string name_;
file_time_type mod_time_;
variables vars_;
};
Listing 64-3.Adding a Move Constructor to the artifact Class
瓦卢瓦还是卢瓦卢瓦?
所有这些 XYZ 值都会让人感到困惑。为了帮助你理解发生了什么,清单 64-4 展示了许多不同的表达式作为参数传递给重载的print()
函数。
import <iostream>;
import <string>;
import <utility>;
void print(std::string&& move)
{
std::cout << "move: " << std::move(move) << '\n';
}
void print(std::string const& copy)
{
std::cout << "copy: " << copy << '\n';
}
int main()
{
std::string a{"a"}, b{"b"}, c{"c"};
print(a);
print(a + b);
print(a + b + c);
print(std::move(a + b));
print(a + std::move(b));
print(std::move(a));
}
Listing 64-4.Examining Expression Categories
预测产量。
当我运行该程序时,我得到以下结果:
copy: a
move: ab
move: abc
move: ab
move: ab
move: a
不能创建对引用的引用。如果一个类型已经是一个引用,比如说一个 typedef,那么在声明中添加一个额外的引用就会折叠成一个引用级别。如果类型是一个左值引用,你总是得到一个左值引用。如果原始类型是右值引用,那么您将获得与原始引用相同的类型,例如:
using lvalue = int&;
using rvalue = int&&;
int i;
lvalue& ref1 = i; // ref1 has type int&
lvalue&& ref2 = i; // ref2 has type int&
rvalue& ref3 = i; // ref3 has type int&
rvalue&& ref4 = 42; // ref4 has type int&&
当使用一个auto
声明并且你不知道源类型是否是一个引用类型或者它是哪种类型的引用时,那么总是让auto
声明成为一个右值引用。例如,如果范围迭代器返回左值引用,那么item
将具有左值引用类型,如果范围迭代器返回右值引用,那么【】将具有右值引用类型。如果迭代器返回非引用类型,那么 item 将是对临时常量值的引用:
for (auto&& item : range) do_something(item);
特殊成员功能
当你写一个移动构造器时,你也应该写一个移动赋值操作符,反之亦然。您还必须考虑是否以及如何编写复制构造器和复制赋值运算符。编译器将通过隐式编写默认实现或删除特殊成员函数来帮助您。这一节将详细介绍编译器的隐式行为和编写您自己的特殊成员函数(构造器、赋值操作符和析构函数)的准则。
编译器可以隐式创建以下任何或所有内容:
-
默认构造器,例如
name()
-
例如,复制构造器
name(name const&)
-
例如,移动构造器
name(name&&)
-
复制赋值运算符,例如
name& operator=(name const&)
-
移动赋值运算符,例如
name& operator=(name&&)
-
例如,
~name()
析构函数
一个好的指导方针是,如果你写这些特殊函数中的任何一个,你应该写所有的函数。您可能会认为编译器的隐式函数正是您想要的,在这种情况下,您应该用=default
明确说明这一点。这有助于维护代码的人了解您的意图。如果你知道编译器会抑制一个特殊的成员,注意用= delete
显式地。
如您所知,如果您显式编写任何构造器,编译器会删除其隐式默认构造器。隐式默认构造器未初始化指针,所以如果您有任何指针类型的数据成员,您应该编写自己的默认构造器来初始化指向nullptr
的指针,或者删除默认构造器。
如果您显式提供移动构造器或移动赋值运算符,编译器将删除其复制构造器和复制赋值运算符。
如果您显式提供以下任何特殊成员函数,编译器将删除其移动构造器:移动赋值、复制构造器、复制赋值或析构函数。
如果您显式提供以下任何特殊成员函数,编译器将删除移动赋值运算符:移动构造器、复制构造器、复制赋值或析构函数。
编译器的默认行为是为了确保安全。如果所有数据成员和基类都允许,它将隐式创建复制和移动函数。但是如果你开始编写自己的特殊成员,编译器会认为你最了解,并抑制任何可能不安全的内容。然后由您来添加对您的类有意义的特殊成员。当编译器隐式提供任何特殊成员函数时,它也会在安全和正确的情况下提供noexcept
说明符,也就是说,当所有的数据成员和基类都声明了函数noexcept
(或者是内置类型)时。
每当一个类使用外部资源时,比如分配堆内存或打开文件,您必须考虑所有特殊的成员函数,以确保资源得到正确管理。通常,为资源提供接口的 C++ 类会为您处理细节。例如,标准的<fstream>
类实现移动逻辑并禁止复制;当流对象被销毁时,它们关闭文件。
确保指针类型的数据成员总是被初始化。确保 move 构造器和赋值操作符正确地将源指针设置为nullptr
。如果必须实现复制构造器或复制赋值运算符,请实现适当的深度复制,或者确保两个对象不都持有相同的指针。确保该类分配的所有内容都被删除。
数据成员的要求适用于该类。因此,如果数据成员缺少复制构造器,则编译器会取消包含类的隐式复制构造器。毕竟,如果它不能复制所有的成员,又怎么能复制类呢?移动功能同上。
所有这些复杂性只适用于实际管理资源的类的作者。即使这样,您也可以利用 C++ 库来简化您的工作。让我们把artifact
课变得更有趣一点。假设variables
映射是一个庞大而笨拙的物体,即使是空的。(其实不是,不过我们假装一下。)所以您希望只有当用户真正为目标定义变量时才创建一个。大多数目标没有variables
映射。因此variables
映射成为一种需要管理的特殊资源。
为了改变artifact
,使它有时有一个variables
映射,有时没有,我们把它改为一个指针,但一种特殊的指针叫做unique_ptr
。这个指针的独特之处在于它有一个独特的所有者,也就是说,一个artifact
拥有一个特定的variables
对象。如果你为artifact
写一个复制构造器,它不能复制unique_ptr
,因为两个artifact
对象将“拥有”同一个variables
映射,所以所有者不是唯一的。但是您可以移动一个unique_ptr
,在移动构造器中将所有权从一个所有者转移到另一个所有者。
要创建一个新的variables
对象,调用std::make_unique<variables>()
。当唯一指针的所有者被销毁时,那么variables
对象也将被销毁。修改artifact
类,将std::unique_ptr
用于variables
映射。将您的解决方案与清单 64-5 进行比较。
export module artifact;
import <filesystem>;
import <memory>;
import <string>;
import <system_error>;
import variables;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact(std::string name)
: name_{std::move(name)}, mod_time_{get_mod_time()}, vars_{}
{}
artifact(artifact const&) = delete; // move-only
artifact& operator=(artifact const&) = delete; // move-only
artifact(artifact&&) = default;
artifact& operator=(artifact&&) = default;
~artifact() = default;
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
file_time_type get_mod_time();
void define(std::string const& name, std::string const& value)
{
if (not vars_)
vars_ = std::make_unique<variables>();
vars_->define(name, value);
}
std::string expand(std::string const& input)
const
{
if (vars_)
return vars_->expand(input);
else
return global_variables.expand(input);
}
private:
std::string name_;
file_time_type mod_time_;
std::unique_ptr<variables> vars_;
};
Listing 64-5.Using a Unique Pointer for the variables Member
当需要时,variables
表被延迟构造。expand()
函数检查它是否存在,如果不存在,只使用全局变量来扩展输入字符串。unique_ptr
类实现了移动指针的所有逻辑,因此确保正确处理variables
资源很容易实现。
unique_ptr
类并不是 C++ 标准库中唯一的智能指针。接下来的探索将带你深入指针丛林。
六十五、智能指针
关于指针,我忽略了一个挑战,那就是使用指针的内在危险。保存所有工件对象的映射必须比程序中所有工件指针的使用寿命都长。到目前为止,这还不是一个问题,但是随着程序规模和复杂性的增长,所涉及的簿记可能会模糊指针的使用方式。因此,让我们探索对程序的一个微小的改变,以确保无论程序如何发展,所有的指针都可以安全使用。这个探索更深入地研究了指针、它们的问题以及如何避免它们。
指针和迭代器
也许你已经注意到迭代器语法和指针语法之间的相似性。C++ 委员会故意设计迭代器来模仿指针。事实上,指针满足了连续迭代器的所有要求,因此您可以使用 C 风格数组的所有标准算法,如下所示:
int data[4];
std::ranges::fill(data, 42);
因此,迭代器是智能指针的一种形式。迭代器尤其聪明,因为它们有六种不同的风格(参见 Exploration 44 以获得提示)。连续迭代器就像指针一样;其他类型的迭代器功能较少,所以它们很聪明。
迭代器和指针一样危险。在它们的纯形式中,迭代器几乎和指针一样未经检查、混乱和原始。毕竟,迭代器不会阻止你前进得太远,不会阻止你去引用一个未初始化的迭代器,不会阻止你比较指向不同容器的迭代器,等等。迭代器的不安全做法非常多。
因为这些错误会导致未定义的行为,所以库实现者可以自由地为每种错误选择任何结果。出于对性能的考虑,大多数库并没有实现额外的安全检查,而是将这一任务推给了程序员,程序员可以根据自己的喜好来决定安全/性能的取舍。
如果程序员更喜欢安全而不是性能,一些库实现提供了一个调试版本,它实现了许多安全检查。标准库的调试版本可以在比较迭代器时检查迭代器是否引用了同一个容器,如果没有,就抛出异常。允许迭代器在使用解引用(*
)操作符之前检查它是否有效。迭代器可以确保它不会超过容器的末尾。
因此,迭代器是智能指针,因为它们非常非常智能。我强烈建议您充分利用标准库提供的所有安全特性。只有在您测量了程序的性能并发现某个特定的检查会显著降低性能,并且您已经准备好了评审和测试以使您对不太安全的代码有信心之后,才能逐个删除检查。
关于unique_ptr
的更多信息
Exploration 64 引入了unique_ptr
作为管理动态分配对象的一种方式。unique_ptr
类模板重载了解除引用(*
)和成员访问(->
)操作符,并允许您像使用指针一样使用unique_ptr
对象。同时,它扩展了普通指针的行为,这样当unique_ptr
对象被销毁时,它会自动删除它持有的指针。这就是为什么unique_ptr
被称为智能指针— 它就像一个普通的指针,只是更智能。使用unique_ptr
有助于确保内存得到适当的管理,即使面对意外的异常。
正确使用时,unique_ptr
的关键特性是恰好一个unique_ptr
对象拥有一个特定的指针。你可以移动unique_ptr
物体。每次这样做时,移动的目标都成为指针的新所有者。
调用reset
成员函数将unique_ptr
设置为空指针。
auto ap{std::make_unique<int>(42)};
ap.reset(); // deletes the pointer to 42
get()
成员函数在不影响unique_ptr
所有权的情况下检索原始指针。unique_ptr
模板还重载了解引用(*
)和成员(->
)操作符,这样它们就可以像处理普通指针一样工作。这些函数不影响指针的所有权。
auto rp{std::make_unique<rational>(420, 10)};
int n{rp->numerator()};
rational r{*rp};
sendto(socket, rp.get(), sizeof(r), n, nullptr, 0);
为了加强它的所有权语义,unique_ptr
有一个移动构造器和移动赋值操作符,但是删除了它的复制构造器和复制赋值操作符。如果对类中的数据成员使用unique_ptr
,编译器会隐式删除该类的复制构造器和复制赋值操作符。
因此,使用unique_ptr
可以让你不用考虑类的析构函数,但是你不能免除考虑构造器和赋值操作符。这是对指导方针的一个小调整,如果你必须处理一个,你必须处理所有的特殊成员。编译器的默认行为通常是正确的,但是您可能希望实现一个复制构造器来执行深度复制或其他非默认行为。
可复制的智能指针
有时候,你不想独占所有权。有些情况下,多个对象会共享一个指针的所有权。当没有对象拥有指针时,内存被自动回收。智能指针类型实现了共享所有权。
一旦你向一个shared_ptr
传递了一个指针,shared_ptr
对象就拥有了这个指针。当shared_ptr
对象被销毁时,它会删除指针。shared_ptr
和unique_ptr
的区别在于,你可以自由的复制和赋值shared_ptr
对象,并带有正常的语义。与unique_ptr
不同,shared_ptr
有复制构造器和复制赋值操作符。shared_ptr
对象保存一个引用计数,所以赋值只是增加引用计数,而不必转移所有权。当一个shared_ptr
对象被销毁时,它会减少引用计数。当计数达到零时,指针被删除。因此,您可以随意复制任意多的副本,将shared_ptr
对象存储在一个容器中,将它们传递给函数,从函数中返回它们,复制它们,移动它们,分配它们,然后随心所欲地继续。就这么简单。清单 65-1 显示了复制shared_ptr
的方式与unique_ptr
不兼容。
import <iostream>;
import <memory>;
import <vector>;
class see_me
{
public:
see_me(int x) : x_{x} { std::cout << "see_me(" << x_ << ")\n"; }
~see_me() { std::cout << "~see_me(" << x_ << ")\n"; }
int value() const { return x_; }
private:
int x_;
};
std::shared_ptr<see_me> does_this_work(std::shared_ptr<see_me> x)
{
std::shared_ptr<see_me> y{x};
return y;
}
int main()
{
std::shared_ptr<see_me> a{}, b{};
a = std::make_shared<see_me>(42);
b = does_this_work(a);
std::vector<std::shared_ptr<see_me>> v{};
v.push_back(a);
v.push_back(b);
}
Listing 65-1.Working with shared_ptr
创建shared_ptr
的最佳方式是调用make_shared
。模板参数是您想要创建的类型,函数参数直接传递给构造器。由于实现细节的原因,以任何其他方式构造一个新的shared_ptr
实例在空间和时间上都稍显低效。
使用shared_ptr
,你可以重新实现清单 63-8 中的程序。旧程序使用artifact
图来管理所有工件的生命周期。虽然很方便,但是没有理由将工件绑定到这个映射上,因为映射只用于解析。在真正的程序中,它的大部分工作在于实际构建目标,而不是解析输入。当程序构建目标时,所有的解析对象都应该被释放并早已消失。
重写清单 63-8 的工件查找部分,动态分配工件对象,通篇使用 shared_ptr
来引用工件指针。参见清单 65-2 了解我的解决方案。
std::unordered_map<std::string, std::shared_ptr<artifact>> artifacts;
std::shared_ptr<artifact>
lookup_artifact(std::string const& name)
{
std::shared_ptr<artifact> a{artifacts[name]};
if (a.get() == nullptr)
{
a = std::make_shared<artifact>(name);
artifacts[name] = a;
}
return a;
}
Listing 65-2.Using Smart Pointers to Manage Artifacts
我将 map 改为unordered_map
,因为每个工件对象的地址永远不变不再重要。存储智能指针减轻了我们的这种限制。稍微小心一点,您可以使用unique_ptr
而不是shared_ptr
,但是这将导致代码的其余部分发生更大的变化。由于维护引用计数的开销,你应该更喜欢unique_ptr
而不是shared_ptr
。但如果你要求共享所有权,shared_ptr
是你的选择。在所有情况下,都没有理由使用原始指针而不是智能指针。
智能阵列
分配单个对象与分配对象数组完全不同。因此,智能指针还必须区分指向单个对象的智能指针和指向对象数组的智能指针。当智能指针持有指向数组的指针时(即模板参数是数组类型,如unique_ptr<int[]>
),它支持下标操作符,而不是*
和->
。
丘疹
不,那不是拼写错误。尽管程序员多年来一直在谈论他们程序中的粉刺和缺陷,通常指的是难看但不可避免的代码片段,Herb Sutter 将短语指向实现的指针与这些粉刺联系起来,提出了 pimpl 习语。
简而言之,pimpl 是一个在实现类中隐藏实现细节的类,公共接口对象只保存指向该实现对象的指针。您可以公开一个更易于使用的类,而不是强制您的类的用户分配和取消分配对象、管理指针以及跟踪对象生存期。具体来说,用户可以按照int
和其他内置类型的方式,将类的实例视为值。
pimpl 包装器管理 pimpl 对象的生命周期。它通常实现特殊的成员函数:复制和移动构造器、复制和移动赋值操作符以及析构函数。它将大多数其他成员函数委托给 pimpl 对象。包装器的用户从来不需要关心这些。
因此,我们将重写artifact
类,以便它包装一个 pimpl——即一个指向artifact_impl
类的指针。artifact_impl
类将完成真正的工作,而artifact
将仅仅通过它的 pimpl 转发所有的函数。模块接口只有一个artifact_impl
的前向声明。声明没有给编译器提供更多关于这个类的信息,所以这个类的类型是不完整的。对于如何处理不完整类型,您面临着许多限制。特别是,您不能定义该类型的任何对象或数据成员,也不能使用不完整的类作为函数参数或返回类型。不能引用不完整类的任何成员。但是在定义对象、数据成员、函数参数和返回类型时,可以使用指向该类型的指针或引用。特别是,你可以在artifact
类中使用一个指向artifact_impl
的指针。
普通的类定义是一个完整的类型定义。您可以将前向声明与相同类名的类定义混合使用。常见的模式是模块接口声明一个前向声明,实现模块填充完整的类定义。
因此,artifact
类的定义可以有一个数据成员,它是指向artifact_impl
的智能指针,即使编译器只知道artifact_impl
是一个类,但不知道它的任何细节。接口模块只包含转发声明。实现细节隐藏在实现模块的单独文件中,程序的其余部分可以使用与artifact_impl
完全隔离的artifact
类。在大型项目中,这种障碍非常重要。
写artifact
接口模块并不难。以artifact_impl
的前向声明开始。在artifact
类中,成员函数的声明与原始类中的相同。将数据成员改为指向artifact_impl
的单个指针。阅读清单 65-3 来看看这个模块的一个可能的实现。
export module artifact;
import <filesystem>;
import <memory>;
import <string>;
class artifact_impl;
export class artifact
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact();
artifact(std::string name);
artifact(artifact const&) = default;
artifact(artifact&&) = default;
artifact& operator=(artifact const&) = default;
artifact& operator=(artifact&&) = default;
~artifact() = default;
std::string const& name() const;
file_time_type mod_time() const;
std::string expand(std::string const& str) const;
void build() const;
file_time_type get_mod_time() const;
void define(std::string const& name, std::string const& value);
private:
std::shared_ptr<artifact_impl> pimpl_;
};
export bool operator==(artifact const& lhs, artifact const& rhs) {
return lhs.name() == rhs.name();
}
namespace std {
template<>
export struct hash<artifact> : std::hash<std::string> {
using super = std::hash<std::string>;
std::size_t operator()(artifact const& a) const {
return super::operator()(a.name());
}
};
}
Listing 65-3.Defining an artifact Pimpl Wrapper Class
该模块定义的artifact
类只有一个artifact_impl
的前向声明和pimpl_
数据成员。因为依赖图将不再存储指针,而是存储artifact
对象,编译器需要知道如何散列artifact
对象,以及如何比较它们是否相等,以便将它们存储在无序容器中。这两个函数仅仅是委托工件的名称。
下一步是编写实现模块。这里编译器需要artifact_impl
类的完整定义,从而使artifact_impl
成为一个完整的类。artifact
类本身做不了多少事情。相反,它只是将每个动作委托给artifact_impl
类。详见清单 65-4 。
module artifact;
import <filesystem>;
import <memory>;
import <string>;
import <system_error>;
import variables;
class artifact_impl
{
public:
using file_time_type = std::filesystem::file_time_type;
artifact_impl() : name_{}, mod_time_{file_time_type::min()}, vars_{} {}
artifact_impl(std::string name)
: name_{std::move(name)}, mod_time_{get_mod_time()}, vars_{}
{}
std::string const& name() const { return name_; }
file_time_type mod_time() const { return mod_time_; }
file_time_type get_mod_time()
const
{
std::error_code ec;
auto time{ std::filesystem::last_write_time(name(), ec) };
if (ec)
return file_time_type::min();
else
return time;
}
void define(std::string const& name, std::string const& value)
{
if (not vars_)
vars_ = std::make_unique<variables>();
vars_->define(name, value);
}
std::string expand(std::string const& input)
const
{
if (vars_)
return vars_->expand(input);
else
return global_variables.expand(input);
}
private:
std::string name_;
file_time_type mod_time_;
std::unique_ptr<variables> vars_;
};
artifact::artifact() : pimpl_{std::make_shared<artifact_impl>()} {}
artifact::artifact(std::string name)
: pimpl_(std::make_shared<artifact_impl>(std::move(name)))
{}
std::string const& artifact::name()
const
{
return pimpl_->name();
}
artifact::file_time_type artifact::mod_time()
const
{
return pimpl_->mod_time();
}
std::string artifact::expand(std::string const& str)
const
{
return pimpl_->expand(str);
}
artifact::file_time_type artifact::get_mod_time()
const
{
return pimpl_->get_mod_time();
}
void artifact::define(std::string const& name, std::string const& value)
{
pimpl_->define(name, value);
}
Listing 65-4.Implementing the artifact Module
artifact_impl
级不足为奇。该实现就像清单 64-5 中的旧artifact
实现一样。
现在是修改depgraph
模块的时候了。这一次,artifacts
映射直接存储artifact
物体。为新的工件类修改清单 63-2 。参见清单 65-5 中重写程序的一种方法。
export module depgraph;
import <iterator>;
import <map>;
import <string>;
import <unordered_map>;
import <unordered_set>;
import artifact;
import topsort;
export class dependency_graph
{
public:
using graph_type = std::unordered_map<artifact,
std::unordered_set<artifact>>;
void store_dependency(std::string const& target_name,
std::string const& dependency_name)
{
auto target{ lookup_artifact(target_name) };
auto dependency{ lookup_artifact(dependency_name) };
store_dependency(target, dependency);
}
graph_type const& graph() const { return graph_; }
template<class OutIter>
requires std::output_iterator<OutIter, artifact>
void sort(OutIter sorted)
const
{
topological_sort(graph_, sorted);
}
artifact lookup_artifact(std::string const& name)
{
auto a{ artifacts_.find(name) };
if (a != artifacts_.end())
return a->second;
else
{
auto [iterator, inserted]{ artifacts_.emplace(name, name) };
return iterator->second;
}
}
private:
void store_dependency(artifact target, artifact dependency)
{
graph_[dependency].insert(target);
graph_[target]; // ensures that target is in the graph
}
graph_type graph_;
std::map<std::string, artifact> artifacts_;
};
Listing 65-5.Rewriting the depgraph Module
如您所见,使用artifact
对象的代码更简单、更易读。管理指针的复杂性被推到了artifact
和artifact_impl
类中。通过这种方式,复杂性保持在一个地方,而不是分散在整个应用程序中。因为使用artifact
的代码现在更简单了,所以包含错误的可能性更小了。因为复杂性是本地化的,所以更容易彻底地审查和测试。代价是多一点开发时间,写两个类而不是一个,和多一点维护工作,因为任何时候在artifact
公共接口中需要一个新函数,那个函数也必须被添加到artifact_impl
。在很多很多情况下,收益远远大于成本,这就是这个成语如此流行的原因。
新的artifact
类易于使用,因为您可以像使用int
一样使用它。也就是说,您可以复制它、分配它、将它存储在一个容器中,等等,而不用担心一个artifact
对象的大小或复制它的成本。不要把一个artifact
当作一个又大又胖的对象,或者一个危险的指针,你可以把它当作一个值。用值语义定义一个类使得它易于使用。尽管实现起来需要更多的工作,但是值artifact
是编写应用程序时最容易使用的实例。
新设计的一个优点是依赖图和构建系统的独立性。(当然,我们还没有编写一个构建系统,但是这可能是任何真正的类似于make
的程序的主要部分。)清单 65-6 显示了主程序的一个小变化,展示了这种可分性。
import <iostream>;
import <iterator>;
import <ranges>;
import <stdexcept>;
import <vector>;
import artifact;
import depgraph;
import parser;
std::vector<artifact> get_dependency_order()
{
dependency_graph graph{};
parse(std::cin, graph);
std::vector<artifact> sorted;
graph.sort(std::back_inserter(sorted));
return sorted;
}
int main()
{
try {
std::vector<artifact> build_list{ get_dependency_order() };
// Print in build order, which is reverse of dependency order.
for (auto artifact : build_list | std::ranges::views::reverse)
{
std::cout << artifact.name() << '\n';
}
} catch (std::runtime_error const& ex) {
std::cerr << ex.what() << '\n';
return EXIT_FAILURE;
}
}
Listing 65-6.New Program Using the parser Module
几个音符。从一个函数中返回一个完整的向量看起来开销很大,但实际上向量被移动到调用者那里,而不是被复制。所以这是从函数中返回字符串列表的合理方式。但更重要的是,依赖图现在是get_dependency_order()
函数中的一个临时变量。以前,它必须在程序的整个生命周期中运行,因为所有的工件指针都指向存储在图中的映射。现在,由于共享指针和 pimpls,程序的独立部分确实是独立的。
哑数组
有一个地方,智能指针和 C++ 的所有强大功能都无法帮助我们。函数拖了我们的后腿,把我们束缚在旧的 C 方式上。最简单的形式,没有争论,我们很好。但是对于一个要学习操作系统或命令 shell 传递给程序的命令行参数的程序来说,main()
还有另一个签名。清单 65-7 展示了一个简单的类似 echo 的程序,演示了程序如何访问命令行参数。
#include <cstring>
import <iostream>;
int main(int argc, char **argv)
{
if (argc > 1) {
if (std::strcmp(argv[1], "--help") == 0)
std::cout << "usage: " << argv[0] << " [ARGS...]";
else {
std::cout << argv[1];
for (int argn{2}; argn < argc; ++argn)
std::cout << ' ' << argv[argn];
}
}
std::cout << '\n';
}
Listing 65-7.Demonstrating Command-Line Arguments
与任何函数一样,函数参数的名称由您决定。名称 argc 和 argv 仅仅是约定俗成的。正如您所看到和猜测的,第一个函数参数(argc
)是命令行参数的数量,第一个是程序名(argv[0]
)。第二个函数参数argv
的声明是一个指向命令行参数字符串的指针数组的指针,每个字符串都是一个以 NUL 终止的数组char
,这就是 C 表示字符串的方式。
函数std::strcmp()
是 C 语言比较以 NUL 结尾的字符串的方式。它是在<cstring>
中声明的,但是因为它是 C 头文件,而不是 C++,所以必须使用 C #include
指令。
argv
数组有一个额外的条目nullptr
,它跟在最后一个命令行参数后面,所以迭代参数的另一种方法是循环直到到达nullptr
。清单 65-8 展示了使用这种风格的 echo 程序。
import <iostream>;
import <string_view>;
int main(int, char **argv)
{
if (argv[1] != nullptr) {
if (std::string_view{argv[1]} == "--help")
std::cout << "usage: " << argv[0] << " [ARGS...]";
else {
std::cout << *++argv;
while (*++argv != nullptr)
std::cout << ' ' << *argv;
}
}
std::cout << '\n';
}
Listing 65-8.Alternative Style of Accessing Command-Line Arguments
从以 NUL 结尾的 C 字符串构造一个std::string_view
是在 C++ 程序中处理这些遗留问题的最好方法。string_view
保存一个指向 C 字符串的指针,而不复制它的内容,所以没有性能损失,并且您可以获得 C++ 字符串的所有便利。图 65-1 展示了 C 如何组织它的命令行参数。
图 65-1。
命令行参数
这就完成了你的指针和内存之旅。现在,您已经知道如何阅读命令行参数,您已经准备好处理文件和文件系统了。
六十六、文件和文件名
除了读写文件,C++ 标准库还具有操作整个文件的功能,例如复制、重命名和删除文件。它也有一个可移植的方式来处理文件名和目录名。让我们看看文件系统库提供了什么。
这个探索中的所有内容都在<filesystem>
中声明,并且位于std::filesystem
名称空间中。虽然我更喜欢使用完全限定的名称,正如我在之前的 60 篇探索中所做的那样,但是这个名称太长了。在本文和后续研究中,假设使用以下名称空间别名:
namespace fsys = std::filesystem;
可移植文件名
<filesystem>
库的关键是一种使用可移植 API 表示文件名和路径的方法,或者至少在使用许多不同文件系统的情况下尽可能地移植,从复杂的网络存储设备到灯泡和其他物联网(IoT)设备。使这成为可能的类是fsys::path
。
fsys::path
类根据根名称、根目录和相对路径来抽象路径名。相对路径是文件名和分隔符的序列。您可以使用文件系统的首选分隔符或'/'
,这称为后备分隔符。它也是 UNIX、POSIX 和类似操作系统的首选分隔符。在微软的 Windows 上,首选的分隔符是'\\'
,它会给 C++ 字符串带来各种各样的麻烦,所以使用'/'
通常更容易,甚至在 Windows 上也是如此。
大多数用于桌面工作站和服务器的现代文件系统可以使用 UTF-8 或 UTF-16 编码处理 Unicode 文件名,但回到物联网和类似设备,它们可能不会。如果您需要确保在尽可能多的环境中的可移植性,您应该将文件名限制为字母数字字符、下划线('_'
)、连字符('-'
)和句点('.'
),它们构成了 POSIX 可移植文件名字符集。
根名称可以是 DOS 驱动器号和冒号或两个分隔符来表示网络名称。它可以是后跟冒号的主机名。根名称的存在是可移植路径 API 的一部分,但是它的含义完全取决于本地环境。目录是分层的,从根开始,由初始分隔符表示。
单个 path 对象可以保存完整的路径名或部分路径名。如果路径包含分隔符,则最后一个分隔符之后的路径部分称为文件名。文件名可以有一个词干,后跟一个扩展名。扩展名是最右边的句点,后跟非句点字符。但是如果唯一的句点是第一个字符,则文件名等于词干,扩展名为空。
您可以从字符串构造一个 path 对象,也可以从多个 path 元素构造一个 path 对象。/
操作符被重载,用一个插入的目录分隔符组合路径。给定一个路径对象,您可以将它分解成组成部分,修改各部分,并添加文件名。
清单 66-1 演示了路径对象的各种用法。
import <filesystem>;
import <iostream>;
namespace fsys = std::filesystem;
int main()
{
std::string line;
while (std::getline(std::cin, line))
{
fsys::path path{line};
std::cout <<
"root-name: " << path.root_name() << "\n"
"root-directory: " << path.root_directory() << "\n"
"relative-path: " << path.relative_path() << "\n"
"parent-path: " << path.parent_path() << "\n"
"filename: " << path.filename() << "\n"
"stem: " << path.stem() << "\n"
"extension: " << path.extension() << "\n"
"generic path: " << path.generic_string() << "\n"
"native path: " << path.string() << '\n';
fsys::path newpath;
newpath = path.root_path() / "top" / "subdir" / "stem.ext";
std::cout << "newpath = " << newpath << '\n';
newpath.replace_filename("newfile.newext");
std::cout << "newpath = " << newpath << '\n';
newpath.replace_extension(".old");
std::cout << "newpath = " << newpath << '\n';
newpath.remove_filename();
std::cout << "newpath = " << newpath << '\n';
}
}
Listing 66-1.Demonstrating the path Class
一些命名空间范围的函数对路径名执行其他操作:
-
path absolute(path const& p)
将p
转换为绝对路径。如果操作系统有当前工作目录的概念,current_path()
将该目录作为path
对象返回,absolute()
可以使用current_path()
作为参数p
的前缀。 -
path canonical(path const& p)
通过删除目录名"."
(当前目录)和".."
(父目录)并解析符号链接,将p
转换为规范路径。 -
path relative(path const& p, path const& base=current_path())
将p
转换为相对于base
的路径。
路径名是你可能遇到国际字符集的另一个地方(探索 59 )。path
类型实际上是针对主机环境的basic_path
的特化,通常是char
或wchar_t
。path 类以依赖于操作系统的方式将字符串转换为路径,然后再转换回来。
使用文件
除了文件名,标准库还提供了许多操作整个文件及其属性的函数。以一种可移植的方式查询和操作文件属性,比如权限、日期和时间等等是一个挑战,本书不能涵盖<filesystem>
模块中的所有复杂性。这一节触及了一些重点。
一般来说,标准 C++ 库从 POSIX 标准中得到启示。例如,文件权限是 POSIX 权限的直接映射,并且不支持访问控制列表等复杂性。类似地,C++ 文件类型是 POSIX 文件类型的映射,比如套接字、管道和字符设备,尽管允许实现添加其他文件类型。
POSIX 为单个文件提供了两种拥有多个名称的方法。这两种方式都称为链节,分为硬链节和软链节。软链接也称为符号链接或简称符号链接。硬链接是直接指向文件内容的目录条目。相同文件内容的两个硬链接无法区分。fsys::hard_link_count()
函数返回指向同一个文件的硬链接的数量。fsys::create_hard_link()
函数创建一个新的硬链接。
符号链接是包含另一个文件路径的目录条目。该路径可以是绝对路径,也可以是相对于包含软链接的目录的路径。使用符号链接可能会导致目标路径不存在。要创建新的符号链接,调用fsys::create_directory_symlink()
链接到一个目录,调用fsys::create_symlink()
链接到一个文件。fsys::read_symlink()
函数可以读取任何一种符号链接的内容。
要查询一个文件的属性,调用status()
,它返回一个fsys::file_status
对象,该对象又有一个permissions()
成员函数来返回文件权限,还有type()
返回文件类型,比如fsys::file_type::regular
。如果文件是符号链接,则返回目标文件的状态。调用fsys::symlink_status()
来获取符号链接本身的状态;如果文件不是符号链接,symlink_status()
就像status()
。此外,fsys::is_regular_file()
和类似的功能存在,以直接查询一个文件类型。
可以通过fsys::last_write_time()
查询和设置文件的修改时间。C++ 标准库在<chrono>
模块中有丰富复杂的日期时间库。它的用途超出了本书的范围。std::format()
函数理解文件时间。在冒号之后,使用{:%F %T}
表示 ISO 日期和时间,或者使用{:%x %X}
表示特定于地区的日期和时间格式。许多其他选项也是可能的。有关详细信息,请查阅最新参考资料。
清单 66-2 通过展示一个非常简单的文件清单程序,类似于 POSIX ls
或 DOS dir
命令,演示了这些函数的使用。对于第一个程序,它在命令行上只需要一个文件名。随着探索的进展,我们将扩展这个小程序的功能。
import <filesystem>;
import <format>;
import <iostream>;
import <iterator>;
namespace fsys = std::filesystem;
void print_file_type(std::ostream& stream, fsys::path const& path)
{
auto status{ fsys::symlink_status(path) };
if (fsys::is_symlink(status)) {
auto link{ fsys::read_symlink(path) };
stream << " -> " << link.generic_string();
}
else if (fsys::is_directory(status))
stream << '/';
else if (fsys::is_fifo(status))
stream << '|';
else if (fsys::is_socket(status))
stream << '=';
else if (fsys::is_character_file(status))
stream << "(c)";
else if (fsys::is_block_file(status))
stream << "(b)";
else if (fsys::is_other(status))
stream << "?";
}
void print_file_info(std::ostream& stream, fsys::path const& path)
{
std::format_to(std::ostreambuf_iterator<char>(stream),
"{0:>16} {1:%F %T} ",
fsys::file_size(path),
fsys::last_write_time(path));
stream << path.generic_string();
print_file_type(stream, path);
stream << '\n';
}
int main(int, char** argv)
{
if (argv[1] == nullptr)
{
std::cerr << "usage: " << argv[0] << " FILENAME\n";
return EXIT_FAILURE;
}
fsys::path path{ argv[1] };
try
{
print_file_info(std::cout, path);
}
catch(fsys::filesystem_error const& ex)
{
std::cerr << ex.what() << '\n';
}
}
Listing 66-2.Demonstrating the path Class
fsys::copy_symlink()
函数顾名思义,创建一个包含现有符号链接副本的新符号链接。fsys::copy_file()
函数创建一个新文件,并将现有文件的内容复制到新文件中。可选的最终参数允许您控制是否允许覆盖现有文件。fsys::copy()
功能结合了其他复印功能及更多功能。复制选项指示它应该如何处理符号链接和目录,甚至允许递归复制整个目录树。
要重命名文件,调用fsys::rename()
,要删除文件,调用fsys::remove()
。要删除整个目录树,调用fsys::remove_all()
。存在许多其他文件级函数;有关详细信息,请查阅好的参考资料。
错误
如果您尝试过运行清单 66-2 中的程序,或者自己做过实验,您可能会发现文件系统库会对任何类型的错误或异常结果抛出异常。通常,您会遇到某些问题,例如,如果用户键入错误的文件名,文件就会丢失。权限错误很常见,等等。所以这个库让你决定是抛出一个异常还是返回一个错误代码。
当您认为错误很常见时,将一个std::error_code
对象作为附加的最终参数传递给任何文件系统函数。该函数将始终存储一个结果,而不是抛出一个异常,如果成功,该异常将为零,如果失败,则为其他值。将error_code
视为布尔值意味着错误为真,成功为假。如果这让你感到困扰,.value()
成员函数将代码作为一个整数返回,你可以显式地与零进行比较。
重写清单 66-2 使用 error_code 代替依赖异常。这也意味着接受多个命令行参数是有意义的。程序可以为每个参数发出一条错误消息,而不是在第一次出错时就终止。您可以将error_code
本身打印到一个输出流中,但这只会显示数字代码。message()
成员函数返回相应的字符串消息。参见我在清单 66-3 中的重写。
import <filesystem>;
import <format>;
import <iostream>;
import <iterator>;
import <string_view>;
import <system_error>;
namespace fsys = std::filesystem;
void print_file_type(std::ostream& stream, fsys::path const& path, fsys::file_status status)
{
if (fsys::is_symlink(status)) {
std::error_code ec;
auto link{ fsys::read_symlink(path, ec) };
if (ec)
stream << ": " << ec.message();
else
stream << " -> " << link.generic_string();
}
else if (fsys::is_directory(status))
stream << '/';
else if (fsys::is_fifo(status))
stream << '|';
else if (fsys::is_socket(status))
stream << '=';
else if (fsys::is_character_file(status))
stream << "(c)";
else if (fsys::is_block_file(status))
stream << "(b)";
else if (fsys::is_other(status))
stream << "?";
}
// There may be many reasons why a file has no size, e.g., it is
// a directory. So don't treat it as an error--just return zero.
uintmax_t get_file_size(fsys::path const& path)
{
std::error_code ec;
auto size{ fsys::file_size(path, ec) };
if (ec.value() != 0)
return 0;
else
return size;
}
// Similarly, return a false timestamp for any error.
fsys::file_time_type get_last_write_time(fsys::path const& path)
{
std::error_code ec;
auto time{ fsys::last_write_time(path, ec) };
if (ec)
return fsys::file_time_type{};
else
return time;
}
void print_file_info(std::ostream& stream, fsys::path const& path)
{
std::error_code ec;
auto status{ fsys::symlink_status(path, ec) };
if (ec)
stream << path.generic_string() << ": " << ec.message();
else
{
std::format_to(std::ostreambuf_iterator<char>(stream),
"{0:>16} {1:%F %T} {2}",
get_file_size(path),
get_last_write_time(path),
path.generic_string());
print_file_type(stream, path, status);
}
stream << '\n';
}
int main(int, char** argv)
{
if (argv[1] == nullptr or std::string_view(argv[1]) == "--help")
{
std::cerr << "usage: " << argv[0] << " FILENAME\n";
return EXIT_FAILURE;
}
while (*++argv != nullptr)
{
fsys::path path{ *argv };
print_file_info(std::cout, path);
}
}
Listing 66-3.Examining Errors with error_code
下一个任务是递归进入目录。下一节将介绍目录条目和迭代器。
导航目录
目录(通常称为文件夹)包含文件条目,可以是任何类型的文件,包括另一个目录。要发现目录中的条目,需要构造一个目录迭代器。使用fsys::directory_iterator
查看单个目录中的条目,或者使用fsys::recursive_directory_iterator
遍历子目录中的条目。像往常一样,用可选的error_code
参数构造带有目录路径的迭代器类型。即使目录迭代器是一个迭代器,它也可以在一个 ranged for
循环或 ranged 函数中用作一个范围。
目录迭代器的值类型是fsys::directory_entry
,它包含文件的名称、状态和其他信息。所有的操作系统和文件系统在细节上有所不同,但是通常迭代目录的行为检索关于文件的信息,因此不需要进行单独的系统调用来获得相同的信息。因此,directory_entry
存储文件状态、修改时间等等,否则您必须调用fsys
函数来获取这些信息。
根据这些信息,你现在可以修改清单 66-3 到目录下。我的版本在清单 66-4 中。请注意我也是如何在命令行中使用directory_entry
命名文件的。这通过一种显示文件信息的方式简化了代码。
import <filesystem>;
import <format>;
import <iostream>;
import <iterator>;
import <system_error>;
namespace fsys = std::filesystem;
void print_file_type(std::ostream& stream, fsys::directory_entry const& entry)
{
auto status{ entry.symlink_status() };
if (fsys::is_symlink(status)) {
std::error_code ec;
auto link{ fsys::read_symlink(entry.path(), ec) };
if (ec)
stream << ": " << ec.message();
else
stream << " -> " << link.generic_string();
}
else if (fsys::is_directory(status))
stream << '/';
else if (fsys::is_fifo(status))
stream << '|';
else if (fsys::is_socket(status))
stream << '=';
else if (fsys::is_character_file(status))
stream << "(c)";
else if (fsys::is_block_file(status))
stream << "(b)";
else if (fsys::is_other(status))
stream << "?";
}
// There may be many reasons why a file has no size, e.g., it is
// a directory. So don't treat it as an error--just return zero.
uintmax_t get_file_size(fsys::directory_entry const& entry)
{
std::error_code ec;
auto size{ entry.file_size(ec) };
if (ec)
return 0;
else
return size;
}
// Similarly, return a false timestamp for any error.
fsys::file_time_type get_last_write_time(fsys::directory_entry const& entry)
{
std::error_code ec;
auto time{ entry.last_write_time(ec) };
if (ec)
return fsys::file_time_type{};
else
return time;
}
void print_file_info(std::ostream& stream, fsys::directory_entry const& entry)
{
std::format_to(std::ostreambuf_iterator<char>(stream),
"{0:>16} {1:%F %T} {2}",
get_file_size(entry),
get_last_write_time(entry),
entry.path().generic_string());
print_file_type(stream, entry);
stream << '\n';
if (not entry.is_symlink() and entry.is_directory())
{
for (auto&& entry : fsys::directory_iterator{entry.path()})
print_file_info(stream, entry);
}
}
int main(int, char** argv)
{
if (argv[1] == nullptr or std::string_view(argv[1]) == "--help")
{
std::cerr << "usage: " << argv[0] << " FILENAME\n";
return EXIT_FAILURE;
}
while (*++argv != nullptr)
{
fsys::path path{ *argv };
std::error_code ec;
fsys::directory_entry entry{ path, ec };
if (ec)
std::cout << *argv << ": " << ec.message() << '\n';
else
print_file_info(std::cout, entry);
}
}
Listing 66-4.Recursing into Directories
下一个主题深入到 C++ 的位和字节。
六十七、处理位
这是一系列探索的开始,涵盖了 C++ 类型系统中更高级的主题。本系列文章首先探讨了如何处理单个位。这种探索从在比特级别操作整数的操作符开始,然后引入比特域——一种完全不同的处理比特的方式。最后一个主题是bitset
类模板,它允许您使用任何大小的位集。
作为一组位的整数
计算机编程中的一个常见习惯是将整数视为位掩码。这些位可以表示一组小整数,使得如果位置 n 处的位为 1,则值 n 是该组的成员;如果相应的位为零,则 n 不在集合中。空集的数值为零,因为所有的位都是零。为了更好地理解这是如何工作的,考虑一下 I/O 流格式化标志(在 Exploration 39 中介绍)。
通常,使用操纵器来设置和清除标志。例如,Exploration 17 推出了skipws
和noskipws
机械手。这些操纵器通过调用setf
和unsetf
成员函数来设置和清除std::ios_base::skipws
标志。换句话说,下面的语句
std::cin >> std::noskipws >> read >> std::skipws;
完全等同于
std::cin.unsetf(std::ios_base::skipws);
std::cin >> read;
std::cin.setf(std::ios_base::skipws);
其他格式标志包括boolalpha
(在探索 12 中引入)、showbase
(探索 58 )、showpoint
(显示小数点,即使它本来会被隐藏),以及showpos
(显示正数的加号)。查阅 C++ 参考资料,了解其余的格式化标志。
格式化标志的一个简单实现是将标志存储在一个int
中,并为每个标志分配一个特定的位位置。编写以这种方式定义的标志的一种常见方式是使用十六进制表示法,如清单 67-1 所示。用0x
或0X
写一个十六进制整数文字,后面跟一个基数为 16 的值。大写或小写的字母A
到F
代表 10 到 15。(C++ 标准并不要求格式化标志的任何特定实现。您的库可能以不同的方式实现格式化标志。)
using fmtflags = int;
fmtflags const showbase = 0x01;
fmtflags const boolalpha = 0x02;
fmtflags const skipws = 0x04;
fmtflags const showpoint = 0x08;
fmtflags const showpos = 0x10;
// etc. for other flags...
Listing 67-1.An Initial Definition of Formatting Flags
下一步是编写setf
和unsetf
函数。前一个函数在flags_
数据成员(属于std::ios_base
类)中设置特定的位,后一个函数清除位。为了设置和清除位,C++ 提供了一些操作整数中各个位的运算符。总的来说,它们被称为按位运算符。
按位运算符执行通常的算术提升和转换(探索 26 )。然后,运算符对其参数中的连续位执行运算。&
运算符实现按位和;|
运算符实现按位包含或;而~
运算符是一元运算符,用于执行按位求补。图 67-1 展示了这些运算符的按位性质(以&
为例)。
图 67-1。
&(按位 and)运算符的工作原理
Operator Abuse
您可能会觉得奇怪(我当然会觉得奇怪),C++ 使用相同的操作符来获取对象的地址,并执行按位和。只有这么多角色可供选择。逻辑和运算符也用于右值引用。星号有双重功能:乘法和指针或迭代器的解引用。区别在于运算符是一元(一个操作数)还是二元(两个操作数)。所以没有歧义,只有不寻常的语法。在后面的探索中,您将了解到 I/O 操作符是改变用途的移位操作符。不过不用担心,你会习惯的。最终。
实现 setf
功能。这个函数接受一个单独的fmtflags
参数,并在flags_
数据成员中设置指定的标志。清单 67-2 显示了一个简单的解决方案。
void setf(fmtflags f)
{
flags_ = flags_ | f;
}
Listing 67-2.A Simple Implementation of the setf Member Function
unsetf
函数稍微复杂一些。它必须清除标志,这意味着将相应的位设置为零。换句话说,该参数指定了一个位掩码,其中每个 1 位意味着清除(设置为 0)在flags_
中的位。编写 unsetf
功能。将您的解决方案与清单 67-3 进行比较。
void unsetf(fmtflags f)
{
flags_ = flags_ & ~f;
}
Listing 67-3.A Simple Implementation of the unsetf Member Function
回想一下 Exploration 49 中,各种赋值运算符将算术运算符与赋值运算符结合在一起。赋值操作符也存在于按位函数中,所以你可以更简洁地编写这些函数,如清单 67-4 所示。
void setf(fmtflags f)
{
flags_ |= f;
}
void unsetf(fmtflags f)
{
flags_ &= ~f;
}
Listing 67-4.Using Assignment Operators in the Flags Functions
回想一下 Exploration 50 中的|
操作符组合了 I/O 模式标志。现在你知道标志是位,I/O 模式是位掩码。如果需要,您可以在 I/O 模式上使用任何按位运算符。
位掩码
并非所有标志都是单独的位。例如,对准标志可以是left
、right
或internal
。浮点型可以是fixed
、scientific
、hexfloat
或通用。要表示三个或四个值,需要两位。对于这些情况,C++ 有一个双参数形式的setf
函数。第一个参数指定要在字段中设置的位掩码,第二个参数指定要影响的位的掩码。
使用相同的位操作符,您可以将adjustfield
定义为两位宽度的位掩码,例如0x300
。如果两位都清零,这可能意味着向左调整;一位设置意味着正确调整;另一个位可能意味着“内部”对齐(在一个符号或十六进制值中的0x
后对齐)。这样就多了一个可能的值(两个位都被设置),但是标准库只定义了三个不同的对齐值。
清单 67-5 显示了adjustfield
和floatfield
掩码及其相关值的一种可能实现。
fmtflags static constexpr adjustfield = 0x300;
fmtflags static constexpr left = 0x000;
fmtflags static constexpr right = 0x100;
fmtflags static constexpr internal = 0x200;
fmtflags static constexpr floatfield = 0xC00;
fmtflags static constexpr scientific = 0x400;
fmtflags static constexpr fixed = 0x800;
fmtflags static constexpr hexfloat = 0xC00;
// general does not have a name; its value is zero
Listing 67-5.Declarations for Formatting Fields
因此,为了将对齐设置为right
,需要调用setf(right, adjustfield)
。写出 setf
函数的双参数形式。将您的解决方案与清单 67-6 进行比较。
void setf(fmtflags flags_to_set, fmtflags field)
{
flags_ &= ~field;
flags_ |= flags_to_set;
}
Listing 67-6.Two-Argument Form of the setf Function
用这种方式定义位域的一个困难是,数值可能很难阅读,除非您花了很多时间处理十六进制值。另一个解决方案是对所有标志和字段使用更熟悉的整数,让计算机通过将这些值转移到正确的位置来完成这项艰巨的工作。
移位
清单 67-7 显示了定义格式化字段的另一种方式。它们表示与清单 67-1 所示完全相同的值,但是它们更容易校对。
int static constexpr boolalpha_pos = 0;
int static constexpr showbase_pos = 1;
int static constexpr showpoint_pos = 2;
int static constexpr showpos_pos = 3;
int static constexpr skipws_pos = 4;
int static constexpr adjust_pos = 5;
int static constexpr adjust_size = 2;
int static constexpr float_pos = 7;
int static constexpr float_size = 2;
fmtflags static constexpr boolalpha = 1 << boolalpha_pos;
fmtflags static constexpr showbase = 1 << showbase_pos;
fmtflags static constexpr showpos = 1 << showpos_pos;
fmtflags static constexpr showpoint = 1 << showpoint_pos;
fmtflags static constexpr skipws = 1 << showpoint_pos;
fmtflags static constexpr adjustfield = 3 << adjust_pos;
fmtflags static constexpr floatfield = 3 << float_pos;
fmtflags static constexpr left = 0 << adjust_pos;
fmtflags static constexpr right = 1 << adjust_pos;
fmtflags static constexpr internal = 2 << adjust_pos;
fmtflags static constexpr fixed = 1 << float_pos;
fmtflags static constexpr scientific = 2 << float_pos;
fmtflags static constexpr hexfloat = 3 << float_pos;
Listing 67-7.Using Shift Operators to Define the Formatting Fields
<<
操作符(看起来就像输出操作符)是左移操作符。它将左边的运算符(必须是整数)移动右边的运算符指定的位数(也是整数)。空出的位用零填充。
1 << 2 == 4
10 << 3 == 80
尽管这种风格更冗长,但您可以清楚地看到这些位是用相邻的值定义的。您也可以很容易地看到多位位掩码的大小。如果您必须添加一个新标志,您可以这样做,而无需重新计算任何其他字段或标志。
什么是 C++ 右移运算符? ________________ 没错:>>
,也是输入运算符。
如果右边的操作数是负的,则反转移位的方向。也就是说,左移负量等同于右移正量,反之亦然。可以对整数使用移位运算符,但不能对浮点数使用。右侧操作数不能大于左侧操作数的位数。(使用探索 26 中介绍的numeric_limits
类模板,来确定一个类型中的位数,比如int
。)
C++ 标准库重载 I/O 流类的移位操作符来实现 I/O 操作符。因此,>>
和<<
运算符是为移位整数中的位而设计的,后来被 I/O 所取代。因此,运算符优先级对于 I/O 来说并不完全正确。特别是,移位运算符的优先级高于位运算符,因为这对于操作位最有意义。因此,例如,如果您想要打印按位运算的结果,则必须用括号将表达式括起来。
std::cout << "5 & 3 = " << (5 & 3) << '\n';
使用右移运算符时需要注意的一点是:填充的位的值是由实现定义的。这对于负数来说尤其成问题。值-1 >> 1
在某些实现上可能是正的,而在其他实现上可能是负的。幸运的是,C++ 有办法避免这种不确定性,下一节将对此进行解释。
无符号类型的安全移位
每个原始整数类型都有一个用关键字unsigned
声明的对应类型。毫不奇怪,这些类型被称为无符号类型。普通(或有符号)整数类型和它们的无符号等效类型之间的一个关键区别是,无符号类型在右移时总是移零。因此,对于实现位掩码,无符号类型比有符号类型更可取。
using fmtflags = unsigned int;
写一个程序来确定你的 C++ 环境如何右移负值。将此与移位无符号值进行比较。你的程序看起来肯定与我的不同,如清单 67-8 所示,但是你应该能够识别出关键的相似之处。
import <iostream>;
import <string_view>;
template<class T>
void print(std::string_view label, T value)
{
std::cout << label << " = ";
std::cout << std::dec << value << " = ";
std::cout.width(8);
std::cout.fill('0');
std::cout << std::hex << std::internal << std::showbase << value << '\n';
}
int main()
{
int i{~0}; // all bits set to 1; on most systems, ~0 == -1
unsigned int u{~0u}; // all bits set to 1
print("int >> 15", i >> 15);
print("unsigned >> 15", u >> 15);
}
Listing 67-8.Exploring How Negative and Unsigned Values Are Shifted
在我的 Linux x86 系统上,我看到以下输出
int >> 15 = -1 = 0xffffffff
unsigned >> 15 = 131071 = 0x01ffff
这意味着右移一个有符号的值会用符号位的副本填充空出的位(这个过程称为符号扩展 n ),右移一个无符号的值会通过移入零位来正确工作。
有符号和无符号类型
普通的int
型是signed
int
的简写。也就是说,int
型有两种标志口味:signed int
和unsigned int
,默认为signed int
。同理,short int
与signed short int
相同,long int
与signed long int
相同。因此,您没有理由对整数类型使用signed
关键字。
然而,和许多规则一样,这一条也有例外:signed char
。char
型有三种口味,而不是两种:char
、signed char
和unsigned char
。这三种类型占用相同的空间(一个字节)。普通char
类型与signed char
或unsigned char
具有相同的表示,但它仍然是一个独特的类型。选择权留给了编译器;查阅你的编译器文档来了解你的实现的等价类型。因此,signed
关键字可以用于signed char
类型;当节省内存很重要时,signed char
最常见的用途是表示一个微小的有符号整数。对文本使用普通的char
,对微小的整数使用signed char
,对微小的位掩码使用unsigned char
。
不幸的是,I/O 流类将signed char
和unsigned char
视为文本,而不是微小的整数或位掩码。因此,读取或写入微小的整数比它应该的要困难。I/O 流类不是读写整数,而是读写单个字符,将signed char
或unsigned char
转换为char
。一个简单的输出解决方案是调用std::format('{0}', byte)
,因为format()
避免了流的 sin,并将char
格式化为字符,bool
格式化为bool
,所有其他整数类型格式化为数字。输入更难。最好的解决方案可能是编写自己的函数来读取一个整数,并将其转换为所需的字节大小的整数类型。
无符号文字
如果整数文字不适合放在signed int
中,编译器会尝试让它适合放在unsigned int
中。如果这行得通,字面量的类型就是unsigned int
。如果该值对于unsigned int
来说太大,编译器会尝试long
,然后unsigned long
,然后long long
,最后unsigned long long
,然后放弃并发出错误消息。
您可以强制一个带有u
或U
后缀的整数为无符号整数。对于一个unsigned long
字面量,U
和L
后缀可以以任何顺序出现。用ULL
代替unsigned long long
。(记住 C++ 允许小写l
,但是我推荐大写L
以避免与数字1
混淆。)
1234u
4321UL
0xFFFFLu
这种灵活性的一个结果是,您不能总是知道整数文字的类型。例如,在 64 位系统上,0xFFFFFFFF
的类型可能是int
。在一些 32 位系统上,类型可能是unsigned int
,而在其他系统上,类型可能是unsigned long
。寓意是确保你写的代码能正确工作,不管整数文字的精确类型是什么,这并不难。例如,本书中的所有程序和片段都可以在任何 C++ 编译器上运行,不管一个int
有多大。
类型转换
有符号类型和它的无符号对应类型总是占据相同的空间。您可以使用static_cast
(Exploration 26 将一个转换成另一个,或者您可以让编译器隐式执行转换,如果您不小心的话,这可能会导致意外。考虑清单 67-9 中的例子。
import <iostream>;
void show(unsigned u)
{
std::cout << u << '\n';
}
int main()
{
int i{-1};
std::cout << i << '\n';
show(i);
}
Listing 67-9.Mixing Signed and Unsigned Integers
这会在我的系统上产生以下输出:
-1
4294967295
如果在一个表达式中混合有符号值和无符号值(通常是一个坏主意),编译器会将有符号值转换为无符号值,这通常会导致更多的意外。这种惊讶往往出现在比较中。大多数编译器至少会警告你这个问题。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
template<class T>
void append(std::vector<T>& data, const T& value, int max_size)
{
if (data.size() < max_size - 1)
data.push_back(value);
}
int main()
{
std::vector<int> data{};
append(data, 10, 3);
append(data, 20, 2);
append(data, 30, 1);
append(data, 40, 0);
append(data, 50, 0);
std::ranges::copy(data, std::ostream_iterator<int>(std::cout, " "));
std::cout << '\n';
}
Listing 67-10.Mystery Program
在运行程序之前,预测什么清单 67-10 将打印。
试试看。你是对的吗?_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _解释这个程序做什么。
程序成功地将10
附加到data
上,因为向量大小为零,小于 2。然而,对append
的下一个调用什么都不做,因为向量大小是1
,而max_size - 1
也是1
。由于类似的原因,下一次调用失败。那么为什么下一个调用成功地将40
追加到了data
?因为max_size
是0
,你可能会认为比较是和-1
进行的,但是-1
是有符号的,而data.size()
是无符号的。因此,编译器将-1
转换为 unsigned,这是一种实现定义的转换。在典型的工作站上,-1
转换成最大的无符号整数,因此测试成功。
这个故事的第一个寓意是避免混合有符号和无符号值的表达式。当您混合有符号和无符号值时,您的编译器可能会通过发出警告来帮助您。无符号值的一个常见来源是标准库中的size()
成员函数,它们都返回一个无符号结果。您可以使用标准的 typedefs 来定义大小,例如std::size_t
,这是一种实现定义的无符号整数类型,从而减少出现意外的机会。标准容器都定义了一个成员类型,size_type
,来表示容器的大小和类似的值。当您知道必须存储大小、索引或计数时,请为变量使用这些 typedefs。
“那容易!”你说。"只要把max_size
的声明改成std::vector<T>::size_type
,问题就解决了!"也许你可以通过坚持使用标准成员类型定义来避免这种问题,比如size_type
和difference_type
(探索 55 )。看看清单 67-11 ,看看你怎么想。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
/** Return the index of a value in a range.
* Look for the first occurrence of @p value in the range
* <tt>first</tt>, <tt>last</tt>), and return the zero-based
* index or -1 if @p value is not found.
* @param first The start of the range to search
* @param last One past the end of the range to search
* @param value The value to search for
* @return [0, size), such that size == last-first, or -1
*/
template<class Range>
requires std::ranges::forward_range<Range>
std::ranges::range_difference_t<Range>
index_of(Range const& range, std::ranges::range_value_t<Range> const& value)
{
auto iter{std::ranges::find(range, value)};
if (iter == std::ranges::end(range))
return -1;
else
return std::distance(std::ranges::begin(range), iter);
}
/** Determine whether the first occurrence of a value in a container is
* in the last position in the container.
* @param container Any standard container
* @param value The value to search for.
* @return true if @p value is at the last position,
* or false if @p value is not found or at any other position.
*/
template<class T>
bool is_last(T const& container, typename T::value_type const& value)
{
return index_of(container, value) == container.size() - 1;
}
int main()
{
std::vector<int> data{};
if (is_last(data, 10))
std::cout << "10 is the last item in data\n";
}
Listing 67-11.Another Mystery Program
在运行清单 [67-11 中的程序之前预测输出。
试试看。你到底得到了什么?
我得到“10 是数据中的最后一项”,尽管data
显然是空的。你能发现我犯的概念性错误吗?在标准容器中,difference_type
typedef 总是有符号整数类型。因此,index_of()
总是返回一个有符号的值。我错误地认为有符号值-1
总是小于任何无符号值,因为它们总是大于或等于0
。因此,is_last()
不必作为特例检查空容器。
我没有考虑到的是,当 C++ 表达式混合了有符号和无符号值时,编译器会将有符号值转换为无符号值。因此,来自index_of
的有符号结果变成无符号,并且-1
变成最大可能的无符号值。如果容器为空,size()
为零,size() - 1
(编译器解释为size() - 1u
)也是最大可能的无符号整数。
如果幸运的话,编译器会发出一个关于比较有符号和无符号值的警告。这给了你一个暗示,有些事情是错误的。修复程序。将您的解决方案与清单 67-12 进行比较。
import <algorithm>;
import <iostream>;
import <iterator>;
import <vector>;
/** Return the index of a value in a range.
* Look for the first occurrence of @p value in the range
* <tt>first</tt>, <tt>last</tt>), and return the zero-based
* index or -1 if @p value is not found.
* @param first The start of the range to search
* @param last One past the end of the range to search
* @param value The value to search for
* @return [0, size), such that size == last-first, or -1
*/
template<class Range>
requires std::ranges::forward_range<Range>
std::ranges::range_difference_t<Range>
index_of(Range const& range, std::ranges::range_value_t<Range> const& value)
{
auto iter{std::ranges::find(range, value)};
if (iter == std::ranges::end(range))
return -1;
else
return std::distance(std::ranges::begin(range), iter);
}
/** Determine
whether the first occurrence of a value in a container is
* in the last position in the container.
* @param container Any standard container
* @param value The value to search for.
* @return true if @p value is at the last position,
* or false if @p value is not found or at any other position.
*/
template<class T>
bool is_last(T const& container, typename T::value_type const& value)
{
auto index{ index_of(container, value) };
decltype(index) last{ container.size() - 1 };
return index == last;
}
int main()
{
std::vector<int> data{};
if (is_last(data, 10))
std::cout << "10 is the last item in data\n";
}
Listing 67-12.Fixing the Second Mystery Program
有许多方法可以确保比较的双方具有相同的类型。decltype()
操作符接受一个表达式并产生该表达式的类型,而不对该表达式求值。在这种情况下,它只是用来匹配变量index
的类型。
这个故事的第二个寓意是,如果没有必要,不要使用无符号类型。大多数时候,有符号类型工作得也很好。仅仅因为一个类型的合法值的范围恰好是非负的,并不是使用无符号类型的理由。这样做只会使任何必须与无符号类型协作的代码变得复杂。
但是,我听你说过,每个带有size()
成员函数的类都返回一个无符号的std::size_t
值。当 C++ 标准库本身没有注意到这个极好的建议时,我如何避免混合有符号和无符号类型呢?作为解决这个问题的一小步,您可以为任何知道其大小的容器或范围调用std::ranges::ssize()
。因此,函数将大小作为有符号整数返回。也许如果ssize()
变得流行,C++ 标准的未来修订版会强制所有容器都使用它。
Tip
使用标准库时,利用它提供的 typedef 和成员 typedef。当您可以控制类型时,请对所有数值类型(包括大小)使用有符号类型,并将无符号类型保留为位掩码。每当你写一个表达式,使用无符号类型和其他整数的时候,一定要非常非常小心。
泛滥
到目前为止,我已经告诉你忽略算术溢出。那是因为这是个很难的话题。严格地说,如果一个包含有符号整数或浮点数的表达式溢出,结果是不确定的。实际上,典型的桌面系统会包装整数溢出(因此将两个正数相加会产生一个负数)。浮点数溢出可能产生无穷大,或者程序可能终止。
如果必须防止溢出,应该在计算表达式之前检查值,以确保表达式不会溢出。使用std::numeric_limits<>
检查该类型的min()
和max()
值。
如果您显式地将一个有符号的值强制转换为一个类型,使得该值溢出目标类型,结果就不会那么糟糕。大多数实现简单地丢弃多余的比特。因此,为了获得最大的安全性和可移植性,您应该检查溢出。使用numeric_limits
(探索 [26 )学习一个类型的最大值或最小值。
无符号整数则不同。该标准明确允许无符号算术溢出。结果是丢弃任何额外的高阶位。从数学上来说,这意味着无符号算术是模 2 n ,其中 n 是无符号类型的位数。如果您必须执行您知道可能溢出的算术运算,并且您希望值回绕而不报告错误,则使用static_cast
转换为相应的无符号类型,执行算术运算,然后static_cast
返回原始类型。static_cast
对性能没有影响,但是它们清楚地告诉编译器和人类正在发生什么。
旋转整数
尽管 C++ 有用于移位的操作符,但它缺少用于旋转的操作符。但是它在<bit>
中有旋转比特、计数比特等功能。清单 67-13 展示了一些这样的函数。
import <bit>;
import <iostream>;
int main()
{
std::cout << std::hex << std::showbase <<
"std::rotl(0x12345678U, 8) = " << std::rotl(0x12345678U, 8) <<
"std::rotr(0x12345678U, 8) = " << std::rotr(0x12345678U, 8) <<
std::dec <<
"std::popcount(0x0110110U) = " << std::popcount(0x0110110U) <<
"std::bit_width(0x00ffffU) = " << std::bit_width(0x00ffffU) <<
'\n';
}
Listing 67-13.Examples from the <bit> Module
<bit>
模块有几个其他的位调整函数,用于计算整数中的位数、测试字符顺序等等。
比特域简介
一个位域是一种将一个类中的整数分割成单个位或相邻位的掩码的方法。使用无符号整数类型或bool
、字段名、冒号和字段中的位数声明一个位字段。清单 67-14 展示了如何使用位域存储 I/O 格式化标志。
struct fmtflags
{
bool skipws_f : 1;
bool boolalpha_f: 1;
bool showpoint_f: 1;
bool showbase_f: 1;
bool showpos_f: 1;
unsigned adjustfield_f: 2;
unsigned floatfield_f: 2;
static unsigned constexpr left = 0;
static unsigned constexpr right = 1;
static unsigned constexpr internal = 2;
static unsigned constexpr fixed = 1;
static unsigned constexpr scientific = 2;
static unsigned constexpr hexfloat = 3;
};
Listing 67-14.Declaring Formatting Flags with Bitfields
像使用任何其他数据成员一样使用位字段成员。例如,要设置skipws
标志,使用
flags.skipws_f = true;
要清除该标志,请使用以下命令:
flags.skipws_f = false;
要选择科学记数法,请尝试以下代码:
flags.floatfield_f = fmtflags::scientific;
如您所见,使用位字段的代码比使用移位和位运算符的等效代码更容易读写。这就是位域受欢迎的原因。另一方面,很难写出setf
、unsetf
这样的函数。很难一次获取或设置多个不相邻的位。这就是为什么你的库可能不使用位域来实现 I/O 格式化标志。
另一个限制是您不能获取位域的地址(使用&
操作符),因为在 C++ 内存模型中,一个单独的位是不可直接寻址的。
尽管如此,当选择一个实现时,位域提供的清晰性将它们放在列表的首位。有时,其他因素会将它们排除在列表之外,但您应该始终首先考虑位字段。有了位字段,您就不必关心按位运算符、移位运算符、混合运算符优先级等等。
轻便
C++ 标准将一些细节留给了每个实现。特别是,字段中的比特顺序由实现决定。一个位域不能跨越一个字边界,在这个字边界上一个字的定义也由实现来决定。流行的桌面和工作站计算机通常使用 32 位或 64 位,但不能保证一个字与一个int
一样大。大小为零的未命名位域告诉编译器插入填充位,以便后续声明在字边界对齐。
class demo {
unsigned bit0 : 1;
unsigned bit1 : 1;
unsigned bit2 : 3;
unsigned : 0;
unsigned word1: 2;
};
一个demo
对象的大小取决于实现。在demo
的实际实现中,bit0
是最低位还是最高位也因系统而异。bit2
和word1
之间的填充位数也取决于实现方式。
大多数代码不需要知道内存中位的布局。另一方面,如果您正在编写解释硬件控制寄存器中的位的代码,您必须知道位的顺序、填充位的确切性质等等。但是无论如何,你可能不期望编写高度可移植的代码。在最常见的情况下,当您试图表达单个集合成员的紧凑集合或小的位掩码时,位域非常有用。它们易于书写和阅读。然而,它们仅限于单个字,通常为 32 位。对于更大的位域,必须使用一个类,比如std::bitset
。
bitset
类模板
有时你必须存储比一个整数所能容纳的更多的位。在这种情况下,您可以使用std::bitset
类模板,它实现了任意大小的固定大小的位串。
std::bitset
类模板接受一个模板参数:集合中的位数。像使用任何其他值一样使用一个bitset
对象。它支持所有的按位和移位操作符,为了更加方便,还支持一些成员函数。bitset
可以执行的另一个巧妙的技巧是下标操作符,它允许你访问集合中作为离散对象的单个位。最右边(最低有效)的位在索引零处。从一个无符号长整型(设置bitset
的最低有效位,将剩余位初始化为零)或从一串'0'
和'1'
字符构造一个bitset
,如清单 67-15 所示。
import <bitset>;
import <iostream>;
/** Find the first 1 bit in a bitset, starting from the most significant bit.
* @param bitset The bitset to examine
* @return A value in the range [0, bitset.size()-1) or
* size_t(-1) if bitset.none() is true.
*/
template<std::size_t N>
std::size_t first(std::bitset<N> const& bitset)
{
for (std::size_t i{bitset.size()}; i-- != 0;)
if (bitset.test(i))
return i;
return std::size_t(-1);
}
int main()
{
std::bitset<50> lots_o_bits{"1011011101111011111011111101111111"};
std::cout << "bitset: " << lots_o_bits << '\n';
std::cout << "first 1 bit: " << first(lots_o_bits) << '\n';
std::cout << "count of 1 bits: " << lots_o_bits.count() << '\n';
lots_o_bits[first(lots_o_bits)] = false;
std::cout << "new first 1 bit: " << first(lots_o_bits) << '\n';
lots_o_bits.flip();
std::cout << "bitset: " << lots_o_bits << '\n';
std::cout << "first 1 bit: " << first(lots_o_bits) << '\n';
}
Listing 67-15.Example of Using std::bitset
在 Exploration 25 中,我将static_cast<>
作为一种将一个整数转换成不同类型的方法。清单 67-14 展示了另一种转换整数类型的方法,使用构造器和初始化器语法:std::size_t(-1)
或std::size{-1}
。对于简单的类型转换,这种语法通常比static_cast<>
更容易阅读。我建议只在转换文字时使用这种语法;对于更复杂的表达式,使用static_cast<>
。
与使用位字段不同,bitset
的大部分行为是完全可移植的。因此,当运行清单 67-14 中的程序时,每个实现都给出相同的结果。以下输出显示了这些结果:
bitset: 00000000000000001011011101111011111011111101111111
first 1 bit: 33
count of 1 bits: 28
new first 1 bit: 31
bitset: 11111111111111111100100010000100000100000010000000
first 1 bit: 49
编写一个函数模板, find_pair
,它需要两个参数:一个 bitset
用于搜索,一个 bool
用于比较。该函数搜索与第二个参数相等的第一对相邻位,并返回该对中最高有效位的索引。如果函数找不到匹配的位对,应该返回什么?也写一个简单的测试程序。
将你的解决方案与我的进行比较,我的解决方案在清单 67-16 中给出。
import <bitset>;
import <iostream>;
template<std::size_t N>
std::size_t find_pair(std::bitset<N> const& bitset, bool value)
{
if (bitset.size() >= 2)
for (std::size_t i{bitset.size()}; i-- != 1; )
if (bitset[i] == value and bitset[i-1] == value)
return i;
return std::size_t(-1);
}
void test(bool condition) {
if (not condition)
throw std::logic_error("test failure");
}
int main()
{
auto constexpr static not_found{static_cast<std::size_t>(-1)};
std::bitset<0> bs0{};
std::bitset<1> bs1{};
std::bitset<2> bs2{};
std::bitset<3> bs3{};
std::bitset<100> bs100{};
test(find_pair(bs0, false) == not_found);
test(find_pair(bs0, true) == not_found);
test(find_pair(bs1, false) == not_found);
test(find_pair(bs1, true) == not_found);
test(find_pair(bs2, false) == 1);
test(find_pair(bs2, true) == not_found);
bs2[0] = true;
test(find_pair(bs2, false) == not_found);
test(find_pair(bs2, true) == not_found);
bs2.flip();
test(find_pair(bs2, false) == not_found);
test(find_pair(bs2, true) == not_found);
bs2[0] = true;
test(find_pair(bs2, false) == not_found);
test(find_pair(bs2, true) == 1);
test(find_pair(bs3, false) == 2);
test(find_pair(bs3, true) == not_found);
bs3[2].flip();
test(find_pair(bs3, false) == 1);
test(find_pair(bs3, true) == not_found);
bs3[1].flip();
test(find_pair(bs3, false) == not_found);
test(find_pair(bs3, true) == 2);
test(find_pair(bs100, false) == 99);
test(find_pair(bs100, true) == not_found);
bs100[50] = true;
test(find_pair(bs100, true) == not_found);
bs100[51] = true;
test(find_pair(bs100, true) == 51);
std::cout << "pass\n";
}
Listing 67-16.The find_pair Function and Test Program
虽然bitset
的应用并不广泛,但是当你需要的时候,它却能提供极大的帮助。下一个探索涵盖了一个比bitset
应用更广泛的语言特性:枚举。
六十八、枚举
C++ 中定义类型的最后一个机制是enum
关键字,它是枚举的缩写。枚举有两种风格。一种风味起源于 C,有一些奇怪的怪癖。另一种风格解决了这些怪癖,可能对你更有意义。这种探索从新口味开始。
限定范围的枚举
枚举类型是用户定义的类型,它将一组标识符定义为该类型的值。用enum class
关键字定义一个枚举类型,后跟新类型的名称,可选的冒号和整数类型,再加上花括号中的枚举文字。随意用struct
代替class
;它们在enum
声明中是可以互换的。以下代码显示了枚举类型的一些示例:
enum class color { black, red, green, yellow, blue, magenta, cyan, white };
enum class sign : char { negative, positive };
enum class flags : unsigned { boolalpha, showbase, showpoint, showpos, skipws };
enum class review : int { scathing = -2, negative, neutral, positive, rave };
限定了作用域的enum
定义定义了一个全新的类型,它不同于所有其他类型。类型名也是作用域名,所有枚举器的名称都在该作用域中声明。因为枚举数是限定了作用域的,所以可以在多个限定了作用域的枚举中使用同一个枚举数名称。
冒号后的类型必须是整数类型。如果省略冒号和 type,编译器会隐式使用int
。这种类型被称为底层类型。枚举值被存储,就像它是基础类型的值一样。
每个枚举器命名一个编译时常量。每个枚举数的类型都是枚举类型。通过将枚举类型强制转换为其基础类型,可以获得整数值,并且可以将整数类型强制转换为枚举类型。编译器将而不是自动执行这些转换。清单 68-1 展示了一种实现std::ios_base::openmode
类型的方法(在 Exploration 14 中刷新你的记忆)。该类型必须支持按位运算符来组合out
、trunc
、app
等,它可以通过将枚举值转换为unsigned
,执行运算,并转换回openmode
来提供这些运算符。
enum class openmode : unsigned char {
in=1, out=2, binary=4, trunc=8, app=16, ate=32
};
openmode operator|(openmode lhs, openmode rhs)
{
return static_cast<openmode>(
static_cast<unsigned>(lhs) | static_cast<unsigned>(rhs) );
}
openmode operator&(openmode lhs, openmode rhs)
{
return static_cast<openmode>(
static_cast<unsigned>(lhs) & static_cast<unsigned>(rhs) );
}
openmode operator~(openmode arg)
{
return static_cast<openmode>( ~static_cast<unsigned>(arg) );
}
Listing 68-1.One Way to Implement openmode Type
声明枚举数时,可以通过在枚举数名称后跟等号和常量表达式来提供整数值。表达式可以使用先前在同一类型中声明的枚举数作为整型常量。如果您省略了一个值,编译器会将前一个值加 1。如果省略第一个枚举数的值,编译器将使用零,例如:
enum class color : unsigned { black, red=0xff0000, green=0x00ff00, blue=0x0000ff,
cyan = blue|green, yellow=red|green, magenta=red|blue, white=red|blue|green };
可以前向声明枚举类型,类似于前向声明类类型的方式。如果省略枚举数的花括号列表,它会告诉编译器类型名及其基础类型,因此您可以使用该类型来声明函数参数、数据成员等。您可以在单独的声明中提供枚举数:
enum class deferred : short;
enum class deferred : short { example, of, forward, declared, enumeration };
向前声明的枚举也称为不透明声明。使用不透明声明的一种方法是在模块接口中声明类型,但在模块实现中提供枚举数。如果不透明类型是在类或命名空间中声明的,请确保在提供完整的类型定义时限定该类型的名称,例如,如果标头包含
export module demo;
export struct demo {
enum hidden : unsigned;
};
源文件可能会声明仅供实现使用而非类用户使用的枚举数,如下所示:
module demo;
enum demo::hidden : unsigned { a, b, c };
编译器隐式定义了枚举数的比较运算符。正如你可能期望的那样,它们通过比较基础的整数值来工作。编译器不提供其他运算符,如 I/O、递增、递减等。
作用域枚举的工作方式与人们期望枚举类型的工作方式非常相似。给定“限定范围”的枚举这个名称,您肯定会问自己,“什么是未限定范围的枚举?”你会大吃一惊的。
未分类枚举
未划分的枚举类型定义了一个新的类型,与其他类型不同。新类型是整数位掩码类型,具有一组预定义的掩码值。如果不指定基础类型,编译器会选择内置整型之一;确切的类型是实现定义的。除了必须省略class
(或struct
)关键字之外,以与作用域枚举相同的方式定义未作用域枚举。
编译器不为枚举类型定义算术运算符,让您自由定义这些运算符。编译器隐式地将未划分范围的枚举值转换为其基础整数值,但是要转换回来,必须使用显式类型强制转换。以带有整数参数的构造器的方式使用枚举类型名,或者使用static_cast
。
enum color { black, blue, green, cyan, red, magenta, yellow, white };
int c1{yellow};
color c2{ color(c1 + 1) };
color c3{ static_cast<color>(c2 + 1) };
将一个enum
称为“位掩码”可能会让你觉得奇怪,但这就是标准如何定义未分类枚举的实现。假设您定义了以下枚举:
enum sample { low=4, high=256 };
类型为sample
的对象的允许值是范围sample(0)
、sample(511)
内的所有值。允许的值是能够容纳枚举数中最大和最小位掩码值的位字段中的所有位掩码值。因此,为了存储值 256,枚举类型必须能够存储多达 9 位。一个副作用是,任何 9 位的值对于枚举类型都是有效的,或者是最大为 511 的整数。
您可以为位字段使用枚举类型(Exploration 66 )。您有责任确保位字段足够大,能够存储所有可能的枚举值,如下所示:
struct demo {
color okay : 3; // big enough
color small : 2; // oops, not enough bits, but valid C++
};
编译器会让你声明一个太小的位域;如果你幸运的话,你的编译器会警告你这个问题。
C++ 从 c 继承了这个对enum
的定义。最早的实现缺乏一个正式的模型,所以当标准委员会试图确定正式的语义时,他们能做的最好的事情就是捕捉现存编译器的行为。他们后来发明了作用域枚举器,试图提供一种合理的方法来定义枚举。
字符串和枚举
作用域和非作用域枚举的一个不足之处是 I/O 支持。C++ 不会隐式地为枚举创建任何 I/O 运算符。你只能靠自己了。未划分的枚举数可以隐式提升为基础类型并打印为整数,但仅此而已。如果要使用枚举器的名称,必须自己实现 I/O 操作符。一个困难是 C++ 不允许你用任何反射或内省机制来发现字面上的名字。
为了实现可以读写字符串的 I/O 操作符,您必须能够将字符串映射到枚举值,反之亦然。大多数枚举类型的文字数量有限,因此创建一个名称作为键、文字作为值的映射通常是可行的。要将值映射到字符串,请线性搜索所有匹配的值。这很简单,而且对于小映射来说,成本也不高。
你将如何实现一个行为像映射但又允许反向查找的类型?
我推荐从std::unordered_map
派生一个类,为反向查找添加一些成员函数。继续编写一个模板类,将枚举类型作为模板参数,并从std::unordered_map
派生。你想添加什么方法?清单 68-2 展示了这样做的一种方法。
export module enums;
import <algorithm>;
import <initializer_list>;
import <stdexcept>;
import <string>;
import <type_traits>;
import <unordered_map>;
export template<class T>
concept is_enum = std::is_enum_v<T>;
export template<class Enum>
requires is_enum<Enum>
class enum_map : public std::unordered_map<std::string, Enum>
{
public:
using enum_type = Enum;
using super = std::unordered_map<std::string, enum_type>;
using value_type = super::value_type;
using key_type = super::key_type;
using mapped_type = super::mapped_type;
using const_iterator = super::const_iterator;
// All the same constructors as the super class.
using super::super;
// But initializer lists require a distinct constructor.
enum_map(std::initializer_list<value_type> list) : super(list) {}
using super::find;
// Lookup by enum value. Return iterator or end().
const_iterator find(mapped_type value) const {
return std::ranges::find_if(*this, value
{
return pair.second == value;
});
}
using super::at;
// Lookup by enum value. Return reference to key or throw
key_type const& at(mapped_type value) const {
if (auto iter = find(value); iter != this->end())
return iter->first;
else
throw std::out_of_range("enum_map::at()");
}
};
Listing 68-2.Defining a Type That Maps Strings to and from Enumerations
当派生类定义了基类中也定义了的成员函数时,派生类会隐藏或隐藏基类函数,因为编译器一旦在派生类中找到该函数就会停止查找。即使基类有更好的(或正确的)函数参数匹配,只要编译器找到任何具有正确名称的函数,搜索就会停止。using
声明将基类函数带入派生类,这样编译器可以找到所有的基类函数,然后选择最合适的一个。因为我们知道键的类型是一个字符串,映射的类型是一个枚举,所以调用find()
或at()
不会有歧义。如果参数是字符串,则调用常规的unordered_map
函数,如果参数是枚举,则调用新的enum_map
函数。
给定enum_map
类型,编写模板 I/O 函数。流操作符没有帮助,因为为了读写字符串或枚举值,还需要enum_map
对象。所以把 read()
和 write()
函数改为。看看你的函数是否和清单 68-3 中的函数相似。
import <istream>;
import <ostream>;
template<class Enum>
std::istream& read(std::istream& stream, enum_map<Enum> const& map, Enum& value)
{
std::string token;
if (stream >> token)
{
value = map.at(token);
}
return stream;
}
template<class Enum>
std::ostream& write(std::ostream& stream, enum_map<Enum> const& map, Enum value)
{
stream << map.at(value);
return stream;
}
Listing 68-3.Defining a Type That Maps Strings to and from Enumerations
用简单的枚举类型测试你的代码。假设你定义了一个language
类型,枚举了你最喜欢的计算机语言。编写一个程序,初始化一个const enum_map
对象,从cin
中读取一些值,并将它们写回cout
。捕捉并报告异常,然后继续循环。我的示例程序是清单 68-4 。
import <iostream>;
import enums;
enum class language { apl, low=apl, cpp, haskell, lisp, scala, high=scala };
enum_map<language> const languages{
{ "apl", language::apl },
{ "c++", language::cpp },
{ "haskell", language::haskell },
{ "lisp", language::lisp },
{ "scala", language::scala }
};
int main()
{
language lang;
while (std::cin)
{
try {
if (read(std::cin, languages, lang)) {
write(std::cout, languages, lang);
std::cout << '\n';
}
}
catch (std::out_of_range const& ex) {
std::cout << ex.what() << '\n';
}
}
}
Listing 68-4.Demonstrating the enum_map Type
宇宙飞船
不,这一节不涉及宇宙飞船的航空电子设备。相反,它告诉你所谓的“宇宙飞船”运算符。更正式的说法是,这个算子的名字叫三向比较算子,不过飞船更好玩。这个比较操作符返回一个枚举值(这就是为什么我还没有提到它)。
宇宙飞船操作符比较两个值,并一次性告诉您一个对象是小于、大于还是等于另一个对象。它还考虑到了对象不可比较的可能性(比如,两个非数字浮点值)。
如果操作数类型允许,操作符返回一个std::strong_ordering
值,可以是less
、equal
或greater
。另一种可能是std::partial_ordering
,它也提供了less
、equal
和greater
文字,以及unordered
。这两种类型都有作用域,所以您需要限定文字。类型在<compare>
中定义。
类型可以重载运算符。例如,rational
可以实现强排序,如清单 68-5 所示。
import <compare>;
template<class T>
std::strong_ordering operator<=>(rational<T> const& lhs, rational<T> const& rhs)
{
if (lhs.denominator() == rhs.denominator())
// The easy case.
return lhs.numerator() <=> rhs.numerator();
else
return lhs.numerator()*rhs.denominator() <=> lhs.denominator()*rhs.numerator();
}
template<class T>
bool operator<(rational<T> const& lhs, rational<T> const& rhs)
{
return std::strong_ordering::less == (lhs <=> rhs);
}
Listing 68-5.Implementing Three-Way Comparison for rational
重访项目
现在您已经了解了所有关于枚举的知识,考虑一下如何改进以前的一些项目。例如,在 Exploration 36 中,我们为point
类编写了一个构造器,它使用一个bool
来区分笛卡尔和极坐标系统。因为true
是指笛卡尔还是极坐标并不明显,所以更好的解决方案是使用枚举类型,如下所示:
enum class coordinate_system : bool { cartesian, polar };
另一个可以用枚举改进的例子是清单 57-5 中的card
类。不要对套装使用int
常量,而是使用枚举。您还可以对等级使用枚举。枚举必须指定枚举器:数字卡和ace
、jack
、queen
、king
。选择合适的值,以便可以在[2,10]到rank
的范围内转换一个整数,并获得所需的值。你必须为suit
和rank
实现operator++
。使用枚举的一个主要改进是不再可能弄错suit
和rank
类型。编写您的新的、改进的card
类,并将其与清单 68-6 中我的解决方案进行比较。
export module card;
import <istream>;
import <ostream>;
export enum class suit { diamonds, clubs, hearts, spades };
export enum class rank { r2=2, r3, r4, r5, r6, r7, r8, r9, r10, jack, queen, king, ace };
export suit& operator++(suit& s)
{
if (s == suit::spades)
s = suit::diamonds;
else
s = static_cast<suit>(static_cast<int>(s) + 1);
return s;
}
export rank operator++(rank& r)
{
if (r == rank::ace)
r = rank::r2;
else
r = static_cast<rank>(static_cast<int>(r) + 1);
return r;
}
/// Represent a standard western playing card.
export class card
{
public:
constexpr card() : rank_(rank::ace), suit_(suit::spades) {}
constexpr card(rank r, suit s) : rank_{r}, suit_{s} {}
constexpr void assign(rank r, suit s);
constexpr suit get_suit() const { return suit_; }
constexpr rank get_rank() const { return rank_; }
private:
rank rank_;
suit suit_;
};
export bool operator==(card a, card b);
export bool operator!=(card a, card b);
export std::ostream& operator<<(std::ostream& out, card c);
export std::istream& operator>>(std::istream& in, card& c);
/// In some games, Aces are high. In other Aces are low. Use different
/// comparison functors depending on the game.
export bool acehigh_compare(card a, card b);
export bool acelow_compare(card a, card b);
/// Generate successive playing cards, in a well-defined order,
/// namely, 2-10, J, Q, K, A. Diamonds first, then Clubs, Hearts, and Spades.
/// Roll-over and start at the beginning again after generating 52 cards.
export class card_generator
{
public:
card_generator();
card operator()();
private:
card card_;
};
Listing 68-6.Improving the card Class with Enumerations
使用枚举还可以改进哪些项目?
六十九、多重继承
与其他一些面向对象的语言不同,C++ 允许一个类有多个基类。这个特性被称为多重继承。其他几种语言允许单个基类,并引入了各种伪继承机制,比如 Java 接口和 Ruby 插件和模块。C++ 中的多重继承是所有这些行为的超集。
多个基类
通过在逗号分隔的列表中列出所有基类来声明多个基类。每个基类都有自己的访问说明符,如下所示:
class derived : public base1, private base2, public base3
{};
与单一继承一样,派生类可以访问其所有基类的所有非私有成员。派生类构造器按照声明的顺序初始化所有基类。如果你必须传递参数给任何基类构造器,在初始化列表中做。与数据成员一样,初始值设定项的顺序无关紧要。只有声明的顺序是重要的,如清单 69-1 所示。
import <iostream>;
import <string>;
import <utility>;
class visible {
public:
visible(std::string msg) : msg_{std::move(msg)} { std::cout << msg_ << '\n'; }
std::string const& msg() const { return msg_; }
private:
std::string msg_;
};
class base1 : public visible {
public:
base1(int x) : visible{"base1 constructed"}, value_{x} {}
int value() const { return value_; }
private:
int value_;
};
class base2 : public visible {
public:
base2(std::string const& str) : visible{"base2{" + str + "} constructed"} {}
};
class base3 : public visible {
public:
base3() : visible{"base3 constructed"} {}
int value() const { return 42; }
};
class derived : public base1, public base2, public base3 {
public:
derived(int i, std::string const& str) : base3{}, base2{str}, base1{i} {}
int value() const { return base1::value() + base3::value(); }
std::string msg() const
{
return base1::msg() + "\n" + base2::msg() + "\n" + base3::msg();
}
};
int main()
{
derived d{42, "example"};
}
Listing 69-1.Demonstrating the Order of Initialization of Base Classes
当你编译程序时,你的编译器可能会发出警告,指出derived
的初始化列表中基类的顺序与初始化器被调用的顺序不匹配。运行该程序演示了基类的顺序控制构造器的顺序,如以下输出所示:
base1 constructed
base2{example} constructed
base3 constructed
图 69-1 展示了清单 69-1 的类层次结构。请注意,base1
、base2
和base3
类都有自己的visible
基类副本。现在不用关注,但是这一点以后会出现,所以要注意。
图 69-1。
清单 69-1 中类的 UML 图
如果两个或更多的基类有一个同名的成员,如果你想访问那个成员,你必须向编译器指明你指的是哪一个。当您访问派生类中的成员时,通过用所需的基类名称限定成员名称来实现这一点。参见清单 69-1 中derived
类的示例。将 main()
功能修改为:
int main()
{
derived d{42, "example"};
std::cout << d.value() << '\n' << d.msg() << '\n';
}
预测新程序的输出。
将您的结果与我得到的以下输出进行比较:
base1 constructed
base2{example} constructed
base3 constructed
84
base1 constructed
base2{example} constructed
base3 constructed
虚拟基类
有时你不想要一个公共基类的单独副本。相反,您需要公共基类的单个实例,并且每个类共享一个公共实例。要共享基类,在声明基类时插入virtual
关键字。virtual
关键字可以在访问说明符之前或之后;惯例是先列出来。
Note
C++ 重载了某些关键字,比如static
、virtual
和delete
。虚拟基类与虚函数没有关系。他们只是碰巧用了同一个关键词。
想象一下,当base1
、base2
和base3
都从基类派生时,将visible
改为虚拟的。你能想到可能会出现的困难吗?
注意,从visible
继承的每个类都向visible
的构造器传递不同的值。如果您想共享visible
的一个实例,您必须选择一个值并坚持使用它。为了实施这一规则,编译器会忽略虚拟基类的所有初始化器,除了它在最具派生类中需要的初始化器(在这种情况下,derived
)。因此,要将visible
更改为虚拟的,不仅必须更改base1
、base2
和base3
的声明,还必须更改derived
。当derived
初始化visible
时,它初始化visible
的唯一共享实例。试试看。您修改后的程序看起来应该类似于清单 69-2 。
import <iostream>;
import <string>;
import <utility>;
class visible {
public:
visible(std::string msg) : msg_{std::move(msg)} { std::cout << msg_ << '\n'; }
std::string const& msg() const { return msg_; }
private:
std::string msg_;
};
class base1 : virtual public visible {
public:
base1(int x) : visible{"base1 constructed"}, value_{x} {}
int value() const { return value_; }
private:
int value_;
};
class base2 : virtual public visible {
public:
base2(std::string const& str) : visible{"base2{" + str + "} constructed"} {}
};
class base3 : virtual public visible {
public:
base3() : visible{"base3 constructed"} {}
int value() const { return 42; }
};
class derived : public base1, public base2, public base3 {
public:
derived(int i, std::string const& str)
: base3{}, base2{str}, base1{i}, visible{"derived"}
{}
int value() const { return base1::value() + base3::value(); }
std::string msg() const
{
return base1::msg() + "\n" + base2::msg() + "\n" + base3::msg();
}
};
int main()
{
derived d{42, "example"};
std::cout << d.value() << '\n' << d.msg() << '\n';
}
Listing 69-2.Changing the Inheritance of Visible to Virtual
预测来自清单 69-2 的输出。
请注意,visible
类现在只初始化一次,初始化它的是derived
类。因此,每个班级的留言都是"derived"
。这个例子不同寻常,因为我想说明虚拟基类是如何工作的。大多数虚拟基类只定义一个默认构造器。这使派生类的作者不必担心向虚拟基类构造器传递参数。相反,每个派生类都调用默认的构造器;哪个类派生得最多并不重要。
图 69-2 描述了新的类图,使用了虚拟继承。
图 69-2。
具有虚拟继承的类图
类似 Java 的接口
使用接口编程有一些重要的优势。能够将接口从实现中分离出来使得在不影响其他代码的情况下更改实现变得容易。如果你必须使用接口,在 C++ 中你可以很容易地做到。
C++ 没有接口的正式概念,但是它支持基于接口的编程。Java 和类似语言中接口的本质是接口没有数据成员,成员函数没有实现。回想一下探索 38 这样的函数叫做纯虚函数。因此,接口仅仅是一个普通的类,其中没有定义任何数据成员,并且将所有成员函数声明为纯虚拟的。
例如,Java 有Hashable
接口,它定义了hash
和equalTo
函数。清单 69-3 展示了等价的 C++ 类。
class Hashable
{
public:
virtual ~Hashable();
virtual unsigned long hash() const = 0;
virtual bool equalTo(Hashable const&) const = 0;
};
Listing 69-3.The Hashable Interface in C++
任何实现Hashable
接口的类都必须覆盖所有的成员函数。例如,HashableString
为字符串实现了Hashable
,如清单 69-4 所示。
class HashableString : public Hashable
{
public:
HashableString() : string_{} {}
~HashableString() override;
unsigned long hash() const override;
bool equalTo(Hashable const&) const override;
// Implement the entire interface of std::string ...
private:
std::string string_;
};
Listing 69-4.The HashableString Class
注意HashableString
不是从std::string
派生而来。相反,它封装了一个字符串,并将所有字符串函数委托给它持有的string_
对象。
不能从std::string
派生的原因和Hashable
包含虚拟析构函数的原因是一样的。回想一下 Exploration 39 中的内容,任何至少有一个虚函数的类都应该将其析构函数设为虚拟的。但是std::string
没有虚拟析构函数。这是操作原始指针的程序中的一个问题。如果HashableString
是从std::string
派生的,并且程序的一部分分配了一个新的HashableString
对象,而另一部分删除了与类型std::string
相同的指针,那么HashableString
析构函数永远不会被调用。这似乎是一个很容易避免的问题,事实也确实如此,但是在大型复杂的程序中,对程序的一个部分进行微小的修改,很容易对程序中不相关的部分产生令人惊讶的影响。
如果HashableString
不是从std::string
派生的,程序如何管理这些哈希字符串?简短的回答是不能。最长的答案是,从 Java 解决方案的角度考虑问题在 C++ 中并不适用,因为 C++ 为这类问题提供了一个更好的解决方案:模板。
界面与模板
正如您所看到的,C++ 支持 Java 风格的接口,但是这种风格的编程会导致困难。有时候,类似 Java 的接口是正确的 C++ 解决方案。然而,在其他情况下,C++ 提供了更好的解决方案,比如模板。
不要写一个HashableString
类,而是写一个hash<>
类模板,并为任何必须存储在哈希表中的类型指定模板。主模板提供默认行为;专为std::string
型的hash<>
。通过这种方式,字符串池可以轻松地存储std::string
指针并适当地销毁字符串对象,哈希表可以计算字符串的哈希值(以及您必须存储在哈希表中的任何其他内容)。清单 69-5 展示了一种编写hash<>
类模板的方法和一种针对std::string
的特化。
export module hash;
import <string>;
export template<class T>
class hash
{
public:
std::size_t operator()(T const& x) const
{
return reinterpret_cast<std::size_t>(&x);
}
};
export template<>
class hash<std::string>
{
public:
std::size_t operator()(std::string const& str) const
{
std::size_t h(0);
for (auto c : str)
h = h << 1 | c;
return h;
}
};
Listing 69-5.The hash<> Class Template
(对了,标准库提供std::hash
,专门针对std::string
。在这次探索中,相信你的库的实现会大大优于 toy 的实现。)
这种方法提供了Hashable
接口的所有功能,但是在某种程度上允许任何类型都是可散列的,而不放弃任何定义良好的行为。此外,hash()
函数不再是虚拟的,甚至可以是一个内联函数。如果在关键性能路径中访问哈希表,那么速度会相当快。
混合食品
在 Ruby 等语言中发现的另一种多重继承方法是混合。mix-in 是一个通常没有数据成员的类,尽管这在 C++ 中并不是必需的(就像在一些语言中一样)。通常,C++ mix-in 是一个类模板,它定义了一些成员函数,这些函数调用模板参数来为这些函数提供输入值。
常见的习惯用法是 mix-in 模板将派生类作为模板参数。mix-in 可以定义返回派生类引用的操作符,以确保派生类的 API 正是用户所期望的。
困惑了吗?你并不孤单。这是 C++ 中一个常见的习惯用法,但是在它变得熟悉和自然之前需要时间。清单 69-6 有助于阐明这种混合是如何工作的。这种混合定义了一个赋值操作符,该操作符按值接受其参数(稍后调用者决定是复制还是移动赋值的源),并将参数与当前值交换。这是定义赋值操作符的几种常见习惯用法之一。
export module mixin;
export template<class T>
class assignment_mixin {
public:
T& operator=(T rhs)
{
rhs.swap(static_cast<T&>(*this));
return static_cast<T&>(*this);
}
};
Listing 69-6.The assignment_mixin Class Template
诀窍在于,mix-in 类不是交换*this
,而是将自己转换为对模板参数T
的引用。这样,mix-in 永远不需要知道任何关于派生类的信息。唯一的要求是,T
类必须是可复制的(因此它可以是赋值函数的一个参数)并且有一个swap
成员函数。
为了使用assignment_mixin
类,使用派生类名称作为模板参数,从assignment_mixin
(以及您希望使用的任何其他 mix-in)派生您的类。清单 69-7 展示了一个类如何使用混合的例子。
import <iostream>;
import <string>;
import <utility>;
import mixin; // Listing 69-6
class thing: public assignment_mixin<thing> {
public:
thing() : value_{} {}
thing(std::string s) : value_{std::move(s)} {}
void swap(thing& other) { value_.swap(other.value_); }
constexpr std::string const& str() const noexcept { return value_; }
private:
std::string value_;
};
int main()
{
thing one{};
thing two{"two"};
one = two;
std::cout << one.str() << '\n';
}
Listing 69-7.Using mix-in Class Template
这个 C++ 成语一开始很难理解,我们来分解一下。首先,考虑一下assignment_mixin
类模板。像许多其他模板一样,它接受单个模板参数。它定义了一个成员函数,恰好是一个重载的赋值操作符。assignment_mixin
没什么特别的。
但是assignment_mixin
有一个重要的属性:编译器可以编译模板,即使模板参数是一个不完整的类。编译器不需要扩展赋值操作符,直到它被使用,并且此时,T
必须是完整的。但是对于这个阶级本身来说,T
可能是不完整的。如果 mix-in 类要声明一个类型为T
的数据成员,那么当 mix-in 被实例化时,编译器会要求T
是一个完整的类型,因为它必须知道 mix-in 的大小。
换句话说,你可以使用assignment_mixin
作为基类,即使模板参数是一个不完整的类。
当编译器处理一个类定义时,一看到类名,它就在当前范围内将该名称记录为不完整的类型。因此,当assignment_mixin<thing>
出现在基类列表中时,编译器能够使用不完整类型thing
作为模板参数来实例化基类模板。
当编译器到达类定义的末尾时,thing
就变成了一个完整的类型。之后,您将能够使用赋值操作符,因为当编译器实例化该模板时,它需要一个完整的类型,并且它已经有了。
受保护访问级别
除了私有和公共访问级别,C++ 还提供了受保护的访问级别。受保护成员只能由类本身和派生类访问。对于所有其他潜在用户来说,受保护的成员是禁区,就像私人成员一样。
大多数成员是私有或公共的。只有在设计类的层次结构,并且希望派生类调用某个成员函数,但不希望其他任何人调用它时,才使用受保护成员。
混合类有时有一个受保护的构造器。这确保了没有人试图构造该类的独立实例。清单 69-8 显示了带有受保护构造器的assignment_mixin
。
export module mixin;
export template<class T>
class assignment_mixin {
public:
T& operator=(T rhs)
{
rhs.swap(static_cast<T&>(*this));
return static_cast<T&>(*this);
}
protected:
assignment_mixin() {}
};
Listing 69-8.Adding a Protected Constructor to the assignment_mixin Class Template
多重继承也出现在 C++ 标准库中。你知道输入的istream
和输出的ostream
。库也有iostream
,所以单个流可以执行输入和输出。如你所料,iostream
来源于istream
和ostream
。唯一的怪癖与多重继承无关:iostream
在<istream>
头中定义。<iostream>
标题定义了名称std::cin
、std::cout
等等。头名是历史的偶然。
下一个探索通过查看策略和特征,继续您对类型的高级研究。
七十、概念、性状和策略
尽管您可能仍然越来越习惯于模板,但是是时候探索一些用于编写模板的常用工具了:概念、性状和策略。用概念编程与特性密切相关,而特性又与策略密切相关。无论是一起还是分开,它们都可能为您引入一种新的编程风格,但是这种风格构成了 C++ 标准库的基础。正如您将在这次探索中发现的,这些技术非常灵活和强大。这篇探索着眼于这些技术以及如何利用它们。
案例研究:迭代器
考虑一下简单的迭代器。考虑std::advance
功能(探索 46 )。函数的作用是改变迭代器指向的位置。advance
函数对容器类型一无所知;它只知道迭代器。然而不知何故,它知道如果你试图提升一个vector
的迭代器,它可以简单地通过向迭代器添加一个整数来实现。但是如果你推进一个list
的迭代器,advance
函数必须一次步进迭代器一个位置,直到它到达期望的目的地。换句话说,advance
函数实现了改变迭代器位置的最佳算法。对于advance
函数来说,唯一可用的信息必须来自迭代器本身,关键的信息是迭代器的种类。特别是,只有随机访问和连续迭代器允许通过加法快速前进。所有其他迭代器都必须遵循循序渐进的方法。双向、随机访问和连续迭代器可以向后,但是向前和输入迭代器不能。(输出迭代器需要赋值来产生输出值,所以不能在输出迭代器上使用advance
。)那么advance
如何知道自己拥有哪种迭代器,如何选择正确的实现呢?
在大多数 OOP 语言中,迭代器将从一个公共基类中派生,该基类将实现一个虚拟的advance
函数。advance
算法将调用那个虚函数,并让普通的面向对象调度处理细节。或者重载会让你定义多个advance
函数,这些函数在基类作为参数类型的使用上有所不同。C++ 当然可以采取任何一种方法,但它没有。
一个简单的解决方案是对重载的advance
函数使用约束。所需的三个函数如下:
-
随机访问和连续迭代器:使用加法
-
双向迭代器:逐步向前或向后
-
向前和输入迭代器:仅逐步向前
<iterator>
模块定义了几个概念,您可以将它们用作约束,以确保每个高级函数定义只适用于适当的迭代器类型。在查看我在清单 70-1 中的解决方案之前,尝试定义 advance
函数。
import <deque>;
import <iostream>;
import <iterator>;
import <list>;
import <string_view>;
import <vector>;
void trace(std::string_view msg)
{
std::cout << msg << '\n';
}
template<class Iterator, class Distance>
requires std::random_access_iterator<Iterator> and std::integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
trace("random access or contiguous advance");
iterator += distance;
}
template<class Iterator, class Distance>
requires std::bidirectional_iterator<Iterator> and std::integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
trace("bidirectional iterator");
for ( ; distance < 0; ++distance)
--iterator;
for ( ; distance > 0; --distance)
++iterator;
}
template<class Iterator, class Distance>
requires std::input_iterator<Iterator> and std::unsigned_integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
trace("forward or input iterator");
for ( ; distance > 0; --distance)
++iterator;
}
template<class Iterator, class Distance>
void test(std::string_view label, Iterator iterator, Distance distance)
{
advance(iterator, distance);
std::cout << label << *iterator << '\n';
}
int main()
{
std::deque<int> deque{ 1, 2, 3 };
test("deque: ", deque.end(), -2);
std::list<int> list{ 1, 2, 3 };
test("list: ", list.end(), -2);
std::vector<int> vector{ 1, 2, 3};
test("vector: ", vector.end(), -2);
test("istream: ", std::istream_iterator<int>{}, 2);
}
Listing 70-1.One Possible Implementation of std::advance
另一种技术使用单个函数,但是依靠类型性状来区分不同的迭代器类别。<iterator>
模块定义了各种模板类,描述迭代器类型的共同性状或属性。最重要的是std::iterator_traits<T>
,它定义了一个成员类型iterator_category
。高级函数可以测试该类型,并将其与std::random_access_iterator_tag
和其他迭代器标记类型进行比较,以确定迭代器的种类。其他性状更加集中,比如incrementable_traits
,它为任何迭代器定义成员difference_type
,该成员或者定义自己的difference_type
内存,或者允许迭代器减法,在这种情况下difference_type
是减法结果的类型。
将迭代器性状与<type_traits>
中定义的其他性状类模板相结合,比如std::is_same<T,U>
,它决定T
和U
是否是同一类型。在这种情况下更有用的是模板is_base_of<B, D>
来测试B
是否是D
的基类。这有助于您,因为迭代器标签类型形成了功能的类层次结构;因此,如果T
是除std::output_iterator_tag
之外的任何迭代器标签类型,则std::is_base_of<std::input_iterator_tag, T>
为真。当一个性状有一个为真的编译时数据成员value
时,该性状为“真”。类型std::true_type
是最常见的表达方式。
将性状用于advance
函数的最简单方法是使用if constexpr
语句。这是一种特殊的条件,在编译时计算。只有当条件为真时,才会编译if constexpr
语句体中的代码。清单 70-2 显示了这种以特质为导向的写作风格advance
。
import <deque>;
import <iostream>;
import <iterator>;
import <list>;
import <string_view>;
import <type_traits>;
import <vector>;
void trace(std::string_view msg)
{
std::cout << msg << '\n';
}
template<class Iterator, class Distance>
requires std::input_iterator<Iterator> and std::integral<Distance>
void advance(Iterator& iterator, Distance distance)
{
using tag = std::iterator_traits<Iterator>::iterator_category;
if constexpr(std::is_base_of<std::random_access_iterator_tag, tag>::value)
{
trace("random access+ iterator");
iterator += distance;
}
else {
trace("input+ iterator");
if constexpr(std::is_base_of<std::bidirectional_iterator_tag,tag>::value)
{
while (distance++ < 0)
--iterator;
}
while (distance-- > 0)
++iterator;
}
}
template<class Iterator, class Distance>
void test(std::string_view label, Iterator iterator, Distance distance)
{
advance(iterator, distance);
std::cout << label << *iterator << '\n';
}
int main()
{
std::deque<int> deque{ 1, 2, 3 };
test("deque: ", deque.end(), -2);
std::list<int> list{ 1, 2, 3 };
test("list: ", list.end(), -2);
std::vector<int> vector{ 1, 2, 3};
test("vector: ", vector.end(), -2);
test("istream: ", std::istream_iterator<int>{}, 2);
}
Listing 70-2.Implementing std::advance with Type Traits
类型性状
标题<type_traits>
(在 Exploration 53 中首次引入)定义了一套描述类型性状的性状模板。它们的范围从简单的查询,比如std::is_integral<>
,告诉你一个类型是否是内置的整型;到更复杂的查询,比如std::is_nothrow_move_constructible<>
,告诉你一个类是否有一个noexcept
移动构造器。一些性状修改类型,例如std::remove_reference<>
,它将int&
转换为int
。
std::move()
函数使用类型性状,这只是标准库中类型性状的一种用法。请记住,它所做的只是将左值转换为右值。它使用remove_reference
从其参数中去除引用,然后添加&&
将结果转换为右值引用,如下所示:
template<class T>
typename std::remove_reference<T>::type&& move(T&& t) noexcept
{
return static_cast<typename std::remove_reference<T>::type&&>(t);
}
注意type
成员 typedef 的使用。这就是类型性状暴露其转换结果的方式。查询性状声明type
是std::true_type
或std::false_type
的 typedef 这些类在编译时声明一个value
成员为true
或false
。尽管您可以创建一个true_type
或false_type
的实例,并在运行时对它们进行评估,但典型的用途是使用它们来特化一个模板。
类型性状通常是定义概念的基础。定义概念是一个高级主题,所以我不会深入讨论它,但是它有助于了解一个概念是如何定义的。知道了类型性状std::is_integral<T>
决定了一个类型是否是内置的整数类型之一,您可以如下定义一个概念integral<T>
:
template<class T>
concept integral = std::is_integral<T>::value;
概念是为约束指定名称的一种方式。等号后面的值是布尔表达式,将其他概念视为布尔值。例如,已知整数是一个概念,is_signed
是一个类型性状,定义概念signed_integral
如下:
template<class T>
concept signed_integral = integral<T> and std::is_signed<T>::value;
概念可以很快变得非常复杂。现在,让我们来处理一个更普通的性状类,std::char_traits
。
案例研究:char_traits
在 C++ 中处理字符的困难之一是char
类型可能是有符号的,也可能是无符号的。一个char
的大小相对于一个int
的大小因编译器而异。有效字符值的范围也因实现的不同而不同,甚至可能在程序运行时改变。一个由来已久的惯例是使用int
来存储一个值,这个值可能是一个char
或者一个标志文件结束的特殊值,但是标准中没有任何东西支持这个惯例。您可能需要使用unsigned int
或long
。
为了编写可移植的代码,您需要一个 traits 类来为要使用的整数类型提供 typedef、文件结束标记的值等等。这正是char_traits
的作用。当您使用std::char_traits<char>::int_type
时,您知道您可以安全地存储任何char
值或文件结束标记(也就是std::char_traits<char>::eof()
)。
标准的istream
类有一个get()
函数,当没有更多的输入时,它返回一个输入字符或特殊的文件结束标记。标准的ostream
类提供put(c)
来写一个角色。使用这些函数和 char_traits
编写一个函数,将它的标准输入复制到它的标准输出,一次一个字符。调用eof()
获得特殊的文件尾值,调用eq_int_type(a,b)
比较字符的两个整数表示是否相等。这两个函数都是char_traits
模板的静态成员函数,您必须用所需的字符类型对其进行实例化。调用to_char_type
将整数表示转换回char
。将您的解决方案与清单 70-3 进行比较。
import <iostream>;
import <string>; // for char_traits
int main()
{
using char_traits = std::char_traits<char>; // for brevity and clarity
char_traits::int_type c{};
while (c = std::cin.get(), not char_traits::eq_int_type(c, char_traits::eof()))
std::cout.put(char_traits::to_char_type(c));
}
Listing 70-3.Using Character Traits when Copying Input to Output
首先,注意循环条件。在 C++ 中,逗号有许多用途,分隔函数和模板声明中的参数,分隔函数调用和初始化器中的值,分隔声明中的声明符,等等。在只需要一个表达式的地方,逗号也可以分隔两个表达式;计算第一个子表达式,丢弃结果,然后计算第二个子表达式。整个表达式的结果是第二个子表达式的结果。在这种情况下,第一个子表达式将get()
赋值给c
,第二个子表达式调用eq_int_type
,因此循环条件的结果是来自eq_int_type
的返回值,测试c
中存储的get
的结果是否等于文件结束标记。另一种编写循环条件的方法如下:
not char_traits::eq_int_type(c = std::cin.get(), char_traits::eof())
我不喜欢在表达式中间隐藏赋值,所以在这种情况下我更喜欢使用逗号运算符。其他开发人员对逗号操作符非常反感。他们更喜欢嵌入式作业风格。另一种解决方案是使用for
循环代替while
循环,如下所示:
for (char_traits::int_type c = std::cin.get();
not char_traits::eq_int_type(c, char_traits::eof());
c = std::cin.get())
for
循环解决方案的优点是限制了变量c
的范围。但是它有重复呼叫std::cin.get()
的缺点。这些解决方案中的任何一个都是可以接受的;选择一种风格并坚持下去。
在这种情况下,char_traits
似乎让一切变得更加复杂。毕竟,当使用==
操作符时,比较两个整数是否相等更加容易和清晰。另一方面,使用成员函数给了库作者增加逻辑的机会,比如检查无效的字符值。
理论上,您可以编写一个char_traits
特化,例如,实现不区分大小写的比较。在这种情况下,eq()
(比较两个字符是否相等)和eq_int_type()
函数肯定需要额外的逻辑。另一方面,您在 Exploration 59 中了解到,不能为许多国际字符集编写这样的性状类,至少在不知道地区的情况下是这样的。
在现实世界中,char_traits
的特化很少见。
尽管如此,char_traits
类模板还是很有趣。一个纯 traits 类模板将只实现 typedef 成员、静态数据成员,有时还会实现一个返回常量的成员函数,比如char_traits::eof()
。这种特质类的另一个好例子是std::numeric_limits
。像eq_int_type()
这样的函数不是 traits,它描述的是一个类型。相反,它们是策略职能。策略类模板包含指定行为或策略的成员函数。下一节讨论策略。
基于策略的编程
一个策略是一个类或类模板,另一个类模板可以使用它来定制它的行为。性状和策略之间的界限是模糊的,但是对我来说,性状是静态的性状,而策略是动态的行为。在标准库中,string 和 stream 类使用char_traits
策略类模板来获得特定于类型的行为,以比较字符、复制字符数组等等。标准库为char
和wchar_t
类型提供了策略实现。
假设您正在尝试编写一个高性能的服务器。在仔细的设计、实现和测试之后,您发现std::string
的性能引入了巨大的开销。在您的特定应用中,内存是充足的,但是处理器时间是宝贵的。如果能够切换开关,将您的std::string
实现从针对空间优化的实现更改为针对速度优化的实现,岂不是很好?相反,您必须编写自己的满足需要的字符串替换。在编写自己的类时,你最终会重写许多成员函数,比如find_first_of
,这些函数与你的特定实现无关,但对大多数字符串实现来说本质上是一样的。真是浪费时间。
想象一下,如果您有一个 string 类模板,它带有一个额外的模板参数,您可以用它来选择字符串的存储机制,根据您的需要替换内存优化或处理器优化的实现,那么您的工作将会多么简单。简而言之,这就是基于策略的编程的全部内容。
在某种程度上,std::string
类提供了这种灵活性,因为std::string
实际上是针对 char 类型和特定内存分配策略的std:
:basic_string
的特化。事实上,所有的标准容器模板(除了array
)都有一个分配器模板参数。编写新的分配器超出了本书的范围,因此,我们将编写一个更简单的、假设的策略类,它可以指导mystring
类的实现。
为了简单起见,本书只实现了std::string
的几个成员函数。完成std::string
的界面留给读者作为练习。清单 70-4 展示了新的字符串类模板和它的一些成员函数。看一看,你就会明白它是如何利用Storage
策略的。
import <algorithm>;
import <string>;
template<class Char, class Storage, class Traits = std::char_traits<Char>>
class mystring {
public:
using value_type = Char;
using size_type = std::size_t;
using iterator = typename Storage::iterator;
using const_iterator = Storage::const_iterator;
mystring() : storage_{} {}
mystring(mystring&&) = default;
mystring(mystring const&) = default;
mystring(Storage const& storage) : storage_{storage} {}
mystring(Char const* ptr, size_type size) : storage_{} {
resize(size);
std::copy(ptr, ptr + size, begin());
}
static constexpr size_type npos = static_cast<size_type>(-1);
mystring& operator=(mystring const&) = default;
mystring& operator=(mystring&&) = default;
void swap(mystring& str) { storage_.swap(str.storage_); }
Char operator[](size_type i) const { return *(storage_.begin() + i); }
Char& operator[](size_type i) { return *(storage_.begin() + i); }
void resize(size_type size, Char value = Char()) {
storage_.resize(size, value);
}
void reserve(size_type size) { storage_.reserve(size); }
size_type size() const noexcept { return storage_.end() - storage_.begin(); }
size_type max_size() const noexcept { return storage_.max_size(); }
bool empty() const noexcept { return size() == 0; }
void clear() { resize(0); }
void push_back(Char c) { resize(size() + 1, c); }
Char const* data() const { return storage_.c_str(); }
Char const* c_str() const { return storage_.c_str(); }
iterator begin() { return storage_.begin(); }
const_iterator begin() const { return storage_.begin(); }
const_iterator cbegin() const { return storage_.begin(); }
iterator end() { return storage_.end(); }
const_iterator end() const { return storage_.end(); }
const_iterator cend() const { return storage_.end(); }
size_type find(mystring const& s, size_type pos = 0) const {
pos = std::min(pos, size());
auto result{ std::search(begin() + pos, end(),
s.begin(), s.end(), Traits::eq) };
if (result == end())
return npos;
else
return static_cast<size_type>(result - begin());
}
private:
Storage storage_;
};
template<class Char, class Storage1, class Storage2, class Traits>
bool operator <(mystring<Char, Storage1, Traits> const& a,
mystring<Char, Storage2, Traits> const& b)
{
return std::lexicographical_compare(
a.begin(), a.end(), b.begin(), b.end(), Traits::lt
);
}
template<class Char, class Storage1, class Storage2, class Traits>
bool operator ==(mystring<Char, Storage1, Traits> const& a,
mystring<Char, Storage2, Traits> const& b)
{
return std::equal(a.begin(), a.end(), b.begin(), b.end(), Traits::eq);
}
Listing 70-4.The mystring Class Template
mystring
类依靠Traits
来比较字符,依靠Storage
来存储字符。Storage
策略必须提供迭代器来访问字符本身和一些基本的成员函数(data
、max_size
、reserve
、resize
、swap
),而mystring
类提供公共接口,比如赋值操作符和搜索成员函数。
公共比较函数使用标准算法和Traits
进行比较。注意比较函数是如何要求它们的两个操作数具有相同的Traits
(否则,字符串如何以有意义的方式进行比较呢?)但允许不同的Storage
。如果您只想知道两个字符串是否包含相同的字符,那么字符串如何存储它们的内容并不重要。
下一步是编写一些存储策略模板。存储策略在字符类型上参数化。最简单的Storage
是vector_storage
,它将字符串内容存储在一个vector
中。回想一下 Exploration 21 中的 C 字符串以空字符结尾。成员函数返回一个指向 C 风格字符数组的指针。为了简化c_str
的实现,vector 在字符串内容后存储一个尾随的空字符。清单 70-5 显示了vector_storage
的部分实现。您可以自己完成实现。
import <vector>;
template<class Char>
class vector_storage {
public:
using size_type = std::size_t;
using value_type = Char;
using iterator = std::vector<Char>::iterator;
using const_iterator = std::vector<Char>::const_iterator;
vector_storage() : string_{1, Char{}} {}
void swap(vector_storage& storage) { string_.swap(storage.string_); }
size_type max_size() const { return string_.max_size() - 1; }
void reserve(size_type size) { string_.reserve(size + 1); }
void resize(size_type newsize, value_type value) {
// if the string grows, overwrite the null character, then resize
if (newsize >= string_.size()) {
string_[string_.size() - 1] = value;
string_.resize(newsize + 1, value);
}
else
string_.resize(newsize + 1);
string_[string_.size() - 1] = Char{};
}
Char const* c_str() const { return &string_[0]; }
iterator begin() { return string_.begin(); }
const_iterator begin() const { return string_.begin(); }
// Skip over the trailing null character at the end of the vector
iterator end() { return string_.end() - 1; }
const_iterator end() const { return string_.end() - 1; }
private:
std::vector<Char> string_;
};
Listing 70-5.The vector_storage Class Template
编写vector_storage
的唯一困难是 vector 存储一个尾随的空字符,所以c_str
函数可以返回一个有效的 C 风格的字符数组。因此,end
函数必须调整它返回的迭代器。
存储策略的另一种可能性是array_storage
,它就像vector_storage
,除了它使用了一个array
。通过使用阵列,所有存储都是本地的。数组大小是字符串的最大容量,但字符串大小可以在该最大值范围内变化。写 array_storage
。将您的结果与我在清单 70-6 中的结果进行比较。
import <algorithm>;
import <stdexcept>;
import <array>;
template<class Char, std::size_t MaxSize>
class array_storage {
public:
using array_type = std::array<Char, MaxSize>;
using size_type = std::size_t;
using value_type = Char;
using iterator = array_type::iterator;
using const_iterator = array_type::const_iterator;
array_storage() : size_(0), string_() { string_[0] = Char(); }
void swap(array_storage& storage) {
string_.swap(storage.string_);
std::swap(size_, storage.size_);
}
size_type max_size() const { return string_.max_size() - 1; }
void reserve(size_type size) {
if (size > max_size()) throw std::length_error("reserve");
}
void resize(size_type newsize, value_type value) {
if (newsize > max_size())
throw std::length_error("resize");
if (newsize > size_)
std::fill(begin() + size_, begin() + newsize, value);
size_ = newsize;
string_[size_] = Char{};
}
Char const* c_str() const { return &string_[0]; }
iterator begin() { return string_.begin(); }
const_iterator begin() const { return string_.begin(); }
iterator end() { return begin() + size_; }
const_iterator end() const { return begin() + size_; }
private:
size_type size_;
array_type string_;
};
Listing 70-6.The array_storage Class Template
编写新的字符串类的一个困难是,您还必须编写新的 I/O 函数。不幸的是,这需要相当多的工作,并且需要对流类模板和流缓冲区有很好的理解。处理填充和字段调整很容易,但是对于 I/O 流还有一些微妙之处我还没有介绍,比如与 C stdio 的集成、绑定输入和输出流以便在要求用户输入之前出现提示,等等。所以只需将我在清单 70-7 中的解决方案复制到mystring
模块中。
template<class Char, class Storage, class Traits>
std::basic_ostream<Char, Traits>&
operator<<(std::basic_ostream<Char, Traits>& stream,
mystring<Char, Storage, Traits> const& string)
{
typename std::basic_ostream<Char, Traits>::sentry sentry{stream};
if (sentry)
{
bool needs_fill{stream.width() != 0 and string.size() > std::size_t(stream.width())};
bool is_left_adjust{
(stream.flags() & std::ios_base::adjustfield) == std::ios_base::left };
if (needs_fill and not is_left_adjust)
{
for (std::size_t i{stream.width() - string.size()}; i != 0; --i)
stream.rdbuf()->sputc(stream.fill());
}
stream.rdbuf()->sputn(string.data(), string.size());
if (needs_fill and is_left_adjust)
{
for (std::size_t i{stream.width() - string.size()}; i != 0; --i)
stream.rdbuf()->sputc(stream.fill());
}
}
stream.width(0);
return stream;
}
Listing 70-7.Output Function for mystring
sentry
类代表流管理一些簿记。输出函数处理填充和调整。如果你对细节很好奇,可以咨询一下好的参考资料。
input 函数还有一个sentry
类,它代表您跳过前导空格。输入函数必须读取字符,直到它到达另一个空白字符或字符串填充或达到宽度限制。我的版本见清单 70-8 。
template<class Char, class Storage, class Traits>
std::basic_istream<Char, Traits>&
operator>>(std::basic_istream<Char, Traits>& stream,
mystring<Char, Storage, Traits>& string)
{
typename std::basic_istream<Char, Traits>::sentry sentry{stream};
if (sentry)
{
std::ctype<Char> const& ctype(
std::use_facet<std::ctype<Char>>(stream.getloc()) );
std::ios_base::iostate state{ std::ios_base::goodbit };
std::size_t max_chars{ string.max_size() };
if (stream.width() != 0 and std::size_t(stream.width()) < max_chars)
max_chars = stream.width();
string.clear();
while (max_chars-- != 0) {
typename Traits::int_type c{ stream.rdbuf()->sgetc() };
if (Traits::eq_int_type(Traits::eof(), c)) {
state |= std::ios_base::eofbit;
break; // is_eof
}
else if (ctype.is(ctype.space, Traits::to_char_type(c)))
break;
else {
string.push_back(Traits::to_char_type(c));
stream.rdbuf()->sbumpc();
}
}
if (string.empty())
state |= std::ios_base::failbit;
stream.setstate(state);
stream.width(0);
}
return stream;
}
Listing 70-8.Input Function for mystring
break
语句立即退出循环。你可能对这句话或类似的话很熟悉。有经验的程序员可能会惊讶,直到现在还没有例子需要这个语句。一个原因是我忽略了错误处理,否则这将是中断循环的一个常见原因。在这种情况下,当输入到达文件结尾或空白时,就该退出循环了。break
的伙伴是continue
,它立即重复循环。在一个for
循环中,continue
评估循环头的迭代部分,然后评估条件。我在现实生活中很少需要使用continue
,也想不出任何使用continue
的合理例子,但我提到它只是为了完整起见。
众所周知,编译器通过将右边操作数的类型mystring
与函数参数的类型进行匹配来找到 I/O 操作符。在这个简单的例子中,您可以很容易地看到编译器如何执行匹配并找到正确的函数。在混合中加入一些名称空间,并添加一些类型转换,一切都会变得有点混乱。下一篇文章将更深入地研究名称空间和 C++ 编译器应用的规则,以便找到重载的函数名(或者找不到重载的函数名,因此如何解决这个问题)。
七十一、名称、命名空间和模板
使用名称空间和模板的基础很简单,也很容易学习。利用参数相关查找(ADL)也很简单:在与类相同的名称空间中声明自由函数和操作符。但有时生活并不那么简单。特别是在使用模板时,您可能会陷入奇怪的角落,编译器会发出奇怪而无用的消息,并且您会意识到您应该在基础知识之外花更多的时间来研究名称、名称空间和模板。
详细的规则可能非常复杂,因为它们必须涵盖所有的病理情况,例如,解释为什么下面的内容是合法的(尽管会导致一些编译器警告)以及它的含义
enum X { X };
void XX(enum X X=::X) { if (enum X X=X) X; }
以及为什么以下行为是非法的:
enum X { X } X;
void XX(X X=X) { if (X X=X) X; }
但是 rational 程序员不会以这种方式编写代码,一些常识性的指导方针对简化复杂的规则大有帮助。因此,这种探索提供了比本书早期更多的细节,但忽略了许多挑剔的细节,这些细节只对模糊 C++ 竞赛的参赛者重要。
通用规则
某些规则适用于所有类型的名称查找。(后续部分将研究特定于各种上下文的规则。)基本规则是,编译器在源代码中看到名字时,必须知道名字的意思。
大多数名称必须在文件中(或者在模块或包含的头文件中)比使用名称的位置更早声明。唯一的例外是在成员函数的定义中:一个名字可以是同一个类的另一个成员,即使该成员在类定义中的声明晚于该名字的使用。名称在一个范围内必须是唯一的,重载函数和运算符除外。如果您试图声明两个可能冲突的名称,例如在同一范围内两个同名的变量,或者在单个类中一个同名的数据成员和成员函数,编译器会发出错误。
函数可以有多个同名的声明,遵循重载的规则,即参数数量或类型必须不同,const
成员函数的限定必须不同,或者约束必须使编译器能够区分重载的函数。
访问规则(public、private 或 protected)对类成员(嵌套类型和 typedefs、数据成员和成员函数)的名称查找规则没有影响。通常的名字查找规则识别正确的声明,然后编译器才检查是否允许访问名字。
名称是否是虚函数的名称对名称查找没有影响。正常情况下会查找该名称,一旦找到该名称,编译器就会检查该名称是否是虚函数的名称。如果是这样,并且通过引用或指针访问对象,编译器将生成必要的代码来执行实际函数的运行时查找。
模板特化不影响名称查找。编译器寻找主模板的声明。一旦找到,编译器就使用模板参数来决定使用哪个特化(如果有的话)。
模板中的名称查找
模板使名称查找变得复杂。特别是,名称可以依赖于模板参数。这种从属名称与非从属名称具有不同的查找规则。依赖名可以根据模板特化中使用的模板参数改变含义。一个特化可以将名称声明为类型,另一个特化可以将其声明为函数。(当然,这样的编程风格是非常不鼓励的,但是 C++ 的规则必须允许这样的可能性。)
非独立名称的查找遵循定义模板的常用名称查找规则。除了在定义模板的地方应用的正常规则之外,依赖名称的查找可以包括在与模板实例化相关联的名称空间中的查找。根据正在执行的名称查找的种类,后面的部分提供了附加的详细信息。
三种名称查找
C++ 定义了三种名称查找:成员访问操作符;以类名、枚举名或命名空间名开头的名称。还有光秃秃的名字。
-
类成员访问操作符是
.
或->
。左边是一个表达式,它产生类类型的对象、对对象的引用或指向对象的指针。圆点(。)需要一个对象或引用,->
需要一个指针或定义了->
操作符的对象。右边是成员名称(数据成员或成员函数)。例如,在表达式cout.width(3)
中,cout
是对象,width
是成员名。 -
一个类、枚举或命名空间名称后面可以跟一个作用域运算符(
::
)和一个名称,比如std::string
或std::string::npos
。该名称被称为由类名、枚举名或命名空间名限定的。范围运算符的左边不能出现其他类型的名称。编译器在类、枚举或命名空间的范围内查找该名称。该名称本身可以是另一个类、枚举数或命名空间名称,也可以是函数、变量或 typedef 名称。比如在std::filesystem::path::value_type
中,std
和filesystem
是命名空间名,path
是类,value_type
是成员 typedef。 -
一个普通的标识符或操作符被称为非限定名。根据上下文,该名称可以是命名空间名称、类型名称、函数名称或对象名称。不同的上下文有稍微不同的查找规则。
接下来的三个部分更详细地描述了每种类型的名称查找。
成员访问运算符
最简单的规则是成员访问操作符。成员访问运算符的左侧(。或->)确定查找的上下文。该对象必须具有类类型(或者指向类类型的指针或引用),并且右边的名称必须是该类或祖先类的数据成员或成员函数。搜索从对象声明的类型开始,继续搜索其基类(或多个类,按照声明的顺序从左到右搜索多个类)及其基类,依此类推,在第一个具有匹配名称的类处停止。
如果名字是一个函数,编译器收集同一个类中同名的所有声明,根据函数和运算符重载的规则选择一个函数。注意,编译器不考虑祖先类中的任何函数。一旦找到姓名,姓名查找就停止。如果您希望基类的名称参与操作符重载,可以在派生类中使用一个using
声明,将基类名称带入派生类上下文中。
在成员函数体中,左边的对象可以是关键字this
,它是指向成员访问操作符左边对象的指针。如果成员函数是用const
限定符声明的,this
是指向const
的指针。如果基类是模板参数或依赖于模板参数,编译器不知道哪些成员可以从基类继承,直到模板被实例化。你应该使用this->
来访问一个继承的成员,告诉编译器这个名字是一个成员名,编译器在实例化模板的时候会查找这个名字。
清单 71-1 展示了成员访问操作符的几种用法。
import <cmath>;
import <iostream>;
template<class T>
class point2d {
public:
point2d(T x, T y) : x_{x}, y_{y} {}
virtual ~point2d() {}
T x() const { return x_; }
T y() const { return y_; }
T abs() const { return std::sqrt(x() * x() + y() * y()); }
virtual void print(std::ostream& stream) const {
stream << '(' << x() << ", " << y() << ')';
}
private:
T x_, y_;
};
template<class T>
class point3d : public point2d<T> {
public:
point3d(T x, T y, T z) : point2d<T>{x, y}, z_{z} {}
T z() const { return z_; }
T abs() const {
return static_cast<T>(std::sqrt(this->x() * this->x() +
this->y() * this->y() +
this->z() * this->z()));
}
virtual void print(std::ostream& stream) const {
stream << '(' << this->x() << ", " << this->y() << ", " << z() << ')';
}
private:
T z_;
};
template<class T>
std::ostream& operator<<(std::ostream& stream, point2d<T> const& pt)
{
pt.print(stream);
return stream;
}
int main()
{
point3d<int> origin{0, 0, 0};
std::cout << "abs(origin) = " << origin.abs() << '\n';
point3d<int> unit{1, 1, 1};
point2d<int>* ptr{ &unit };
std::cout << "abs(unit) = " << ptr->abs() << '\n';
std::cout << "*ptr = " << *ptr << '\n';
}
Listing 71-1.Member Access Operators
main()
函数以您习惯的方式使用成员名称查找。这种用法很容易理解,在本书中你一直在使用它。然而,在point3d::abs()
中使用成员名称查找更有趣。需要使用this->
,因为基类point2d<T>
依赖于模板参数T
,这意味着编译器在模板被实例化之前不知道基类。只有这样,它才能知道x()
和y()
是从基类继承的还是从其他上下文继承的。当它编译abs()
时,它需要知道如何处理x()
和y()
,所以使用this->x()
和this->y()
告诉编译器在模板实例化时期望在基类中找到这些成员函数。如果找不到它们,它将发出一条错误消息。
operator<<
函数引用一个point2d
实例并调用它的print
函数。虚拟函数被分派给真实函数,在本例中是point3d::print
。你知道这是如何工作的,所以这只是提醒编译器如何在point2d
类模板中查找名字print
,因为那是pt
函数参数的类型。
合格名称查找
一个限定的名使用了作用域(::
)操作符。从第一个程序开始,你就使用了限定名。名称std::string
是限定的,这意味着在由std::
限定符指定的上下文中查找名称string
。在这个简单的例子中,std
命名了一个名称空间,所以在这个名称空间中查找string
。
限定符也可以是类名或限定范围的枚举的名称。类名可以嵌套,所以作用域操作符的左边和右边可能是类名。如果左边的名称是枚举类型的名称,那么右边的名称必须是该类型的枚举数。
编译器从最左边的名字开始搜索。如果最左边的名字以一个作用域操作符开始(例如,::std::string
),编译器会在全局作用域中查找这个名字。否则,它使用非限定名称的常规名称查找规则(如下一节所述)来确定将用于范围运算符右侧的范围。如果右边的名称后面跟有另一个作用域运算符,则所标识的名称必须是命名空间、类或作用域枚举的名称,编译器会在该作用域中查找右边的名称。这个过程一直重复,直到编译器找到最右边的名字。
在一个名称空间中,using
指令告诉编译器在目标名称空间以及包含using
指令的名称空间中进行搜索。在下面的例子中,限定名ns2::Integer
告诉编译器在名称空间ns2
中搜索名字Integer
。因为ns2
包含一个using
指令,编译器也在名称空间ns1
中搜索,因此找到了Integer
typedef。
namespace ns1 { typedef int Integer; }
namespace ns2 { using namespace ns1; }
namespace ns3 { ns2::Integer x; }
一个using
声明略有不同。一个using
指令影响编译器搜索哪些名称空间来找到一个名字。一个using
声明并没有改变要搜索的名称空间集,而只是给包含它的名称空间增加了一个名字。在下面的例子中,using
声明将名称Integer
带入名称空间ns2
,就像 typedef 是在ns2
中编写的一样。
namespace ns1 { typedef int Integer; }
namespace ns2 { using ns1::Integer; }
namespace ns3 { ns2::Integer x; }
当一个名字依赖于一个模板参数时,编译器必须知道这个名字是属于一个类型还是其他类型(函数或对象),因为它影响编译器如何解析模板体。因为名称是依赖的,所以它可能是一个特化中的类型,也可能是另一个特化中的函数。所以你必须告诉编译器会发生什么。如果名字应该是一个类型,在限定名前面加上关键字typename
。如果没有typename
关键字,编译器会假设这个名字是一个函数或对象的名字。你需要一个依赖类型的typename
关键字,但是如果你在一个非依赖类型之前提供它也没什么坏处。
清单 71-2 展示了几个限定名的例子。
import <chrono>;
import <iostream>;
namespace outer {
namespace inner {
class base {
public:
int value() const { return 1; }
static int value(long x) { return static_cast<int>(x); }
};
}
template<class T>
class derived : public inner::base {
public:
typedef T value_type;
using inner::base::value;
static value_type example;
value_type value(value_type i) const { return i * example; }
};
template<class T>
typename derived<T>::value_type derived<T>::example = 2;
}
template<class T>
class more_derived : public outer::derived<T>{
public:
typedef outer::derived<T> base_type;
typedef typename base_type::value_type value_type;
more_derived(value_type v) : value_{this->value(v)} {}
value_type get_value() const { return value_; }
private:
value_type value_;
};
int main()
{
std::chrono::system_clock::time_point now{std::chrono::system_clock::now()};
std::cout << now.time_since_epoch().count() << '\n';
outer::derived<int> d;
std::cout << d.value() << '\n';
std::cout << d.value(42L) << '\n';
std::cout << outer::inner::base::value(2) << '\n';
more_derived<int> md(2);
std::cout << md.get_value() << '\n';
}
Listing 71-2.Qualified Name Lookup
标准的chrono
库使用嵌套的名称空间std::chrono
。在这个名称空间中,system_clock
类有一个成员 typedef、time_point
和一个函数now()
。
now()
函数是静态的,所以它是作为限定名调用的,而不是通过使用成员访问操作符。虽然它对一个对象进行操作,但它的行为就像一个自由函数。now()
和一个完全自由的函数的唯一区别是它的名字是由类名而不是名称空间名限定的。探索 41 简要介绍了静态函数。它们不常使用,但这是这种函数有用的实例之一。now()
函数是用static
限定符声明的,这意味着函数不需要对象,函数体没有this
指针,调用函数的通常方式是用限定名。
数据成员也可以是static
。成员函数(普通的或静态的)通常可以引用静态数据成员,或者您可以使用限定名从类外部访问该成员。静态成员函数和自由函数的另一个区别是,静态成员函数可以访问类的私有静态成员。如果声明静态数据成员,还必须为该成员提供定义,通常是在定义成员函数的同一源文件中。回想一下,非静态数据成员没有定义,因为数据成员的实例是在创建包含对象时创建的。静态数据成员独立于任何对象,因此它们也必须独立定义。
在清单 71-2 中,对d.value()
的第一次调用调用base::value()
。没有derived
中的using
声明,value()
的唯一签名是value(value_type i)
,它与value()
不匹配,因此会导致编译错误。但是using inner::base::value
声明注入了来自inner::base
的value
名称,添加了函数value()
和值(long)
作为重载名称value
的附加函数。因此,当编译器查找d.value()
时,它会搜索所有三个签名,以找到using
声明注入到derived
中的value()
。第二个调用d.value(42L)
,调用value(long)
。即使该函数是静态的,也可以使用成员访问操作符来调用它。编译器忽略该对象,但使用该对象的类型作为查找名称的上下文。对value(2)
的最后一次调用是由类名限定的,所以它只搜索类base
中的value
函数,找到value(long)
,并将int 2
转换为long
。
在most_derived
类模板中,基类依赖于模板参数T
。因此,base_type
typedef 是依赖的。编译器需要知道base_type::value_type
是什么,所以typename
关键字通知编译器value_type
是一个类型。
非限定名称查找
没有成员访问操作符或限定符的名字是不合格的。查找非限定名的精确规则取决于上下文。例如,在成员函数内部,编译器先搜索该类的其他成员,然后搜索继承成员,最后搜索该类的命名空间和外部命名空间。
这些规则是常识性的,但是很复杂,而且细节主要适用于编译器的编写者,他们必须确保所有的细节都是正确的。对于大多数程序员来说,你可以通过常识和一些指导方针来应付:
-
首先在本地范围内查找名称,然后在外部范围内查找。
-
在一个类中,先在类成员中查找名字,然后在祖先类中查找。
-
在模板中,编译器必须在定义模板时解析每个非限定对象和类型名,而不考虑实例化上下文。因此,如果基类依赖于模板参数,它不会而不是在基类中搜索名称。
-
如果在类或祖先中找不到某个名称,或者如果在任何类上下文之外调用该名称,编译器将搜索直接命名空间,然后是外部命名空间。
-
如果名称是函数或运算符,编译器还会根据参数相关查找(ADL)规则搜索函数参数的命名空间及其外部命名空间。在模板中,搜索模板声明和实例化的名称空间。
清单 71-3 包含了几个非限定名称查找的例子。
import <iostream>;
namespace outer {
namespace inner {
struct point { int x, y; };
inline std::ostream& operator<<(std::ostream& stream, point const& p)
{
stream << '(' << p.x << ", " << p.y << ')';
return stream;
}
}
}
typedef int Integer;
int main()
{
const int multiplier{2};
for (Integer i : { 1, 2, 3}) {
outer::inner::point p{ i, i * multiplier };
std::cout << p << '\n';
}
}
Listing 71-3.Unqualified Name Lookup
依赖于参数的查找
非限定名称查找最有趣的形式是依赖参数的查找。顾名思义,编译器在由函数参数确定的名称空间中查找函数名。作为一项准则,编译器尽可能地集合最广泛、最具包容性的类和命名空间,以最大化名称的搜索空间。
更准确地说,如果搜索找到一个成员函数,编译器不会应用 ADL,搜索会在那里停止。否则,编译器会汇编一组额外的类和命名空间进行搜索,并将它们与它在普通查找中搜索的命名空间组合在一起。编译器通过检查函数参数的类型来构建这个额外的集合。对于每个函数参数,声明参数类型的类或命名空间被添加到集合中。此外,如果参数的类型是类,则还会添加祖先类及其命名空间。如果参数是指针,则附加的类和命名空间是基类型的类和命名空间。如果您将函数作为参数传递,则该函数的参数类型将被添加到搜索空间中。当编译器搜索附加的 ADL 专用命名空间时,它只搜索匹配的函数名,而忽略类型和变量。
如果函数是模板,附加的类和命名空间包括定义模板和实例化模板的类和命名空间。
清单 71-4 展示了几个参数相关查找的例子。该清单在名称空间numeric
中使用了 Exploration 53 中rational
的定义。
import <cmath>;
import <iostream>;
import rational;
namespace data {
template<class T>
struct point {
T x, y;
};
template<class Ch, class Tr, class T>
std::basic_ostream<Ch, Tr>& operator<<(std::basic_ostream<Ch, Tr>& stream, point<T> const& pt)
{
stream << '(' << pt.x << ", " << pt.y << ')';
return stream;
}
template<class T>
T abs(point<T> const& pt) {
using namespace std;
return sqrt(pt.x * pt.x + pt.y * pt.y);
}
}
namespace numeric {
template<class T>
rational<T> sqrt(rational<T> r)
{
using std::sqrt;
return rational<T>{sqrt(static_cast<double>(r))};
}
}
int main()
{
using namespace std;
data::point<numeric::rational<int>> a{ numeric::rational<int>{1, 2}, numeric::rational<int>{2, 4} };
std::cout << "abs(" << a << ") = " << abs(a) << '\n';
}
Listing 71-4.Argument-Dependent Name Lookup
从main()
开始,按照名称查找。
第一个名字是data
,查出来是个不合格的名字。编译器找到了在全局名称空间中声明的名称空间data
。然后编译器知道在data
名称空间中查找point
,并找到类模板。类似地,编译器查找numeric
,然后查找rational
。
编译器构造a
并将名称添加到局部范围。
编译器先查找std
,然后查找cout
,因为cout
是在<iostream>
头文件中声明的。接下来,编译器在局部范围内查找非限定名a
。但是之后它还得查abs
。
编译器首先在局部范围内搜索,然后在全局范围内搜索。using
指令告诉编译器也搜索名称空间std
。这耗尽了正常查找的可能性,因此编译器必须转向依赖于参数的查找。
编译器集合它要搜索的范围。首先,它将data
添加到要搜索的名称空间中。因为point
是一个模板,编译器也会搜索模板被实例化的名称空间,也就是全局名称空间。已经搜过了,不过没关系。一旦集合完成,编译器在名称空间data
中搜索并找到abs
。
为了实例化模板abs
,使用模板参数numeric::rational<int>
,编译器必须查找operator*
。它无法在本地范围、名称空间data
、名称空间std
或全局名称空间中找到声明。使用依赖于参数的查找,它在声明了rational
的numeric
名称空间中找到operator*
。它对operator+
执行相同的查找。
为了找到sqrt
,编译器再次使用依赖于参数的查找。当我们上次访问 rational 类时,它缺少一个sqrt
函数,所以清单 71-4 提供了一个粗略的函数。它将rational
转换为double
,调用sqrt
,然后再转换回rational
。编译器在名称空间std
中找到sqrt
。
最后,编译器必须再次对operator<<
应用依赖于参数的查找。当编译器在point
中编译operator<<
时,它不知道rational
的operator<<
,但在模板被实例化之前它不必知道。如您所见,如果您遵循简单的原则,编写利用参数相关查找的代码是很简单的。下一篇文章将进一步研究解析重载函数和操作符的规则。同样,你会发现复杂的规则可以通过遵循一些基本准则变得简单。
七十二、重载的函数和运算符
探索 25 引入了重载函数的概念。探索号 31 带着超载的运算符继续旅程。从那以后,我们设法对重载有了一个常识性的理解。如果我没有更深入地研究这个主题,那将是我的失职,所以让我们通过更深入地研究重载函数和操作符的规则来结束重载的故事。(运算符和函数遵循相同的规则,因此在此探索中,理解函数同样适用于函数和用户定义的运算符。)
类型变换
在跳入重载池的深水区之前,我需要填补一些关于类型转换的缺失部分。回想一下 Exploration 26 中,编译器将某些类型提升为其他类型,比如short
到int
。它还可以将一种类型(如int
)转换为另一种类型(如long
)。
将一种类型转换为另一种类型的另一种方法是使用单参数构造器。您可以将rational{1}
视为将int
文字1
转换为rational
类型的一种方式。当您声明一个单参数构造器时,您可以告诉编译器您是希望它隐式执行这种类型转换,还是需要显式类型转换。也就是说,如果构造器是隐式的(默认),那么声明其参数类型为rational
的函数可以接受一个整数参数,编译器自动从int
构造一个rational
对象,如下所示:
rational reciprocal(rational const& r)
{
return rational{r.denominator(), r.numerator()};
}
rational half{ reciprocal(2) };
要禁止这种隐式构造,请在构造器上使用explicit
说明符。这迫使用户显式命名该类型,以便调用构造器。例如,std::vector
有一个构造器,它将一个整数作为唯一的参数,用默认初始化的元素初始化 vector。构造器是explicit
以避免如下语句:
std::vector<int> v;
v = 42;
如果构造器不是explicit
,编译器会自动从整数 42 构造一个vector
,并将这个vector
赋给v
。因为构造器是explicit
,编译器停止并报告一个错误。
将一种类型转换为另一种类型的另一种方法是使用类型转换运算符。编写这样一个带有关键字operator
的操作符,后跟目的地类型。像单参数构造器一样,您可以将类型转换操作符声明为explicit
。代替rational
中的convert
或as_float
函数,您也可以编写类型转换操作符,如下所示:
explicit operator float() const {
return float(numerator()) / float(denominator());
}
编译器自动调用类型转换操作符的一个上下文是循环或if
-语句条件。因为您在条件中使用表达式,并且条件必须是布尔型的,所以编译器认为这种使用是到类型bool
的显式转换。如果你为类型bool
实现一个类型转换操作符,总是使用explicit
说明符。您将能够在一个条件中测试您的类型的对象,并且您将避免一个令人讨厌的问题,即编译器将您的类型转换为bool
,然后将bool
提升为int
。你不会真的想写,比如:
int i;
i = std::cin; // if conversion to bool were not explicit, i would get 0 or 1
在下面关于重载决策的讨论中,类型转换起着重要的作用。编译器不关心如何将一种类型转换为另一种类型,只关心是否必须执行转换,以及转换是内置在语言中还是用户定义的。构造器相当于类型转换运算符。
重载函数综述
让我们回忆一下。当两个或更多的函数声明在同一个作用域中声明相同的名字时,函数名被重载。C++ 对何时允许重载函数名施加了一些限制。
主要限制是重载函数必须有不同的参数列表。这意味着参数的数量必须不同,或者至少一个参数的类型必须不同。
void print(int value);
void print(double value); // valid overload: different argument type
void print(int value, int width); // valid overload: different number of arguments
当两个函数仅在返回类型上不同时,不允许在同一范围内定义两个函数。
void print(int);
int print(int); // illegal
成员函数也可以因有无const
限定符而不同。
class demo {
void print();
void print() const; // valid: const qualifier is different
};
成员函数不能与同一类中的静态成员函数重载。
class demo {
void print();
static void print(); // illegal
};
关键的一点是重载发生在单个作用域内。一个作用域中的名称对另一个作用域中的名称没有影响。记住一个代码块就是一个作用域(Exploration 13 ),一个类就是一个作用域(Exploration 41 ),一个命名空间就是一个作用域(Exploration 56 )。
因此,基类中的成员函数在该类的作用域内,不会影响派生类中的名称重载,派生类有自己的作用域,与基类的作用域分开且不同。
当您在派生类中定义函数时,它会隐藏基类或外部范围中具有相同名称的所有函数,即使这些函数采用不同的参数。该规则是内部作用域中的名称隐藏外部作用域中的名称这一一般规则的一个具体示例。因此,派生类中的任何名称都隐藏了基类和命名空间范围中的名称。块中的任何名称都会隐藏外部块中的名称,依此类推。从派生类中调用隐藏函数的唯一方法是限定函数名,如清单 72-1 所示。
import <iostream>;
class base {
public:
void print(int x) { std::cout << "int: " << x << '\n'; }
};
class derived : public base {
public:
void print(double x) { std::cout << "double: " << x << '\n'; }
};
int main()
{
derived d{};
d.print(3); // prints double: 3
d.print(3.0); // prints double: 3
d.base::print(3); // prints int: 3
d.base::print(3.0); // prints int: 3
}
Listing 72-1.Qualifying a Member Function with the Base Class Name
但是,有时您希望重载考虑派生类中的函数以及基类中的函数。解决方案是将基类名称注入派生类范围。使用声明(探索 52 )通过来实现这一点。**修改清单72-1所以** derived
既可以看到 的打印功能。改变main
,这样它用一个int
参数和一个double
参数调用d.print
,没有限定名。你期望什么产出?
尝试一下,并将您的结果与清单 72-2 中的结果进行比较。
import <iostream>;
class base {
public:
void print(int x) { std::cout << "int: " << x << '\n'; }
};
class derived : public base {
public:
void print(double x) { std::cout << "double: " << x << '\n'; }
using base::print;
};
int main()
{
derived d{};
d.print(3); // prints int: 3
d.print(3.0); // prints double: 3
}
Listing 72-2.Overloading Named with a using Declaration
一个using
声明导入了所有同名的重载函数。为了看到这一点,将 print(long)
添加到基类中,并对 main
进行相应的函数调用。现在你的例子应该看起来类似于清单 72-3 。
import <iostream>;
class base {
public:
void print(int x) { std::cout << "int: " << x << '\n'; }
void print(long x) { std::cout << "long: " << x << '\n'; }
};
class derived : public base {
public:
void print(double x) { std::cout << "double: " << x << '\n'; }
using base::print;
};
int main()
{
derived d{};
d.print(3); // prints int: 3
d.print(3.0); // prints double: 3
d.print(3L); // prints long: 3
}
Listing 72-3.Adding a Base Class Overload
过载规则通常运行良好。你可以清楚地看到,对于main
中的每个函数调用,编译器选择了哪个print
函数。然而,有时规则变得更加模糊。
例如,假设您要将行d.print(3.0f);
添加到main
。你希望程序打印出什么?
编译器将float 3.0f
提升为类型double
并调用print(double)
,因此输出如下:
double: 3
那太容易了。来个short
怎么样?试试 d.print(short(3))
。会发生什么?
编译器将short
提升为类型int
,并产生以下输出:
int: 3
这还是太容易了。现在试试unsigned
。加 d.print(3u)
。会发生什么?
那根本不管用,是吗?错误消息可能说了一些关于不明确的重载或函数调用的事情。要理解哪里出了问题,您需要更好地理解重载在 C++ 中是如何工作的,这也是本文余下部分的全部内容。
霸王决议
编译器应用其正常的查找规则(Exploration 71 )来查找函数名的声明。当编译器找到所需名称的第一个匹配项时,它将停止搜索,但是该范围可能有多个同名的声明。对于类型或变量,这将是一个错误,但函数可能有多个或重载的同名声明。
在编译器为它正在查找的函数名找到一个声明后,它会在同一作用域中找到该名称的所有函数声明,并应用其重载规则来选择它认为最匹配函数参数的一个声明。这个过程叫做解析重载的名字。
为了解决重载问题,编译器会考虑参数及其类型、函数声明中函数参数的类型,以及转换参数类型以匹配参数类型所需的类型转换和提升。像姓名查找一样,详细的规则是复杂的,微妙的,有时令人惊讶。但是如果你避免编写病态重载,你通常可以通过一些常识性的指导方针。
编译器找到函数名声明后,重载决策开始。编译器收集同一范围内同名的所有声明。这意味着编译器不包含来自任何基类或祖先类的同名函数。一个using
声明可以将这样的名字带入派生类范围,从而让它们参与重载解析。如果函数名是非限定的,编译器会寻找成员函数和非成员函数。另一方面,using
指令对重载解析没有影响,因为它不改变名称空间中的任何名称。
如果函数是构造器,并且有一个参数,编译器还会考虑返回所需类或派生类的类型转换运算符。
然后,编译器会丢弃任何参数个数错误的函数,或者那些函数参数无法转换为相应的参数类型的函数。它检查约束,并且不考虑任何约束检查失败的函数。当匹配成员函数时,编译器会添加一个隐式参数,这是一个指向对象的指针,就像this
是一个函数参数一样。
最后,编译器通过测量将每个实参转换为相应的形参类型需要做什么来对所有剩余的函数进行排序,这将在下一节中解释。如果有一个具有最佳排名的唯一获胜者,则编译器已经成功地解决了重载。如果没有,编译器会应用一些平局决胜规则来尝试选择排名最好的函数。如果编译器不能选出一个获胜者,它将报告一个模糊错误。如果它有一个获胜者,它将继续下一个编译步骤,即检查成员函数的访问级别。排名最佳的重载可能不可访问,但这并不影响编译器解决重载的方式。
排名功能
为了对函数进行排序,编译器确定如何将每个参数转换为相应的参数类型。执行摘要是,排名最好的函数是需要最少的工作来将所有参数转换为所需的参数类型的函数。
编译器有几个工具可以将一种类型转换成另一种类型。其中许多你已经在本书前面看到过,比如提升算术类型(探索 26 ),将派生类引用转换为基类引用(探索 37 ),或者调用类型转换操作符。编译器将一系列转换组合成一个隐式转换序列 (ICS)。ICS 是一系列小的转换步骤,编译器可以将这些步骤应用于函数调用参数,最终结果是将参数转换为相应函数参数的类型。
编译器有排序规则来决定一个 ICS 是否比另一个更好。编译器试图找到一个函数,对于该函数,每个参数的 ICS 是所有重载名称中最好的(或并列最好的)ICS,并且至少有一个 ICS 无疑是最好的。如果是这样的话,它会选择排名最高的函数。否则,如果它有一组函数都与最佳 ICSes 集相匹配,它将进入加时赛,如下一节所述。本节的剩余部分将讨论编译器如何对 ICSes 进行排序。
首先,一些术语。ICS 可能涉及标准转换或用户定义的转换。标准转换是 C++ 语言所固有的,比如算术转换。一个用户定义的转换涉及类和枚举类型上的构造器和类型转换操作符。标准 IC是只包含标准转换的 IC。一个用户定义的 IC由一系列标准转换组成,序列中任何地方都有一个用户定义的转换。(因此,任何需要两次用户派生转换才能将实参转换为形参类型的重载都不会达到这一步,因为编译器无法将实参转换为形参类型,因此不再考虑该函数签名。)
例如,将short
转换为const int
是一个标准的 ICS,有两个步骤:将short
提升为int
并添加const
限定符。将字符串文字转换为std::string
是一个用户定义的 ICS,它包含一个标准转换(将const char
的数组转换为指向const char
的指针),后面是一个用户定义的转换(std::string
构造器)。
一个例外是,调用复制构造器将相同的源和目标类型或派生类源复制到基类类型是标准转换,而不是用户定义的转换,即使这些转换调用用户定义的复制构造器。
编译器必须选择仍在考虑中的函数的最佳集成电路。作为这一决定的一部分,它必须能够在一个 ICS 中比较标准转换。标准转换分为三类。按照从最好到最差的顺序,类别是精确匹配、推广和其他转换。
精确匹配是指实参类型与形参类型相同。精确匹配转换的示例如下:
-
只改变限定条件,例如,自变量是类型
int
,参数是const int
(但不是指向const
的指针或对const
的引用) -
将数组转换为指针(探索 59 ,例如
char[10]
到char*
-
将左值转换为右值,例如,
int&
到int
一个提升(探索 26 )是从较小的算术类型(如short
)到较大类型(如int
)的隐式转换。编译器认为提升比转换更好,因为提升不会丢失任何信息,但转换可能会。
所有其他隐式类型转换—例如,丢弃信息的算术转换(如long
到int
)和基类指针的派生类指针—属于杂项转换的最后一类。
序列的类别是序列中最差转换步骤的类别。例如,将short
转换为const int
涉及一个精确匹配 ( const
)和一个提升 ( short
到int
),因此 ICS 作为一个整体的类别是提升。
如果一个参数是隐式对象参数(用于成员函数调用),编译器也会比较它所需的任何转换。
现在您知道了编译器如何按类别对标准转换进行排序,您可以看到它如何使用这些信息来比较 ICSes。编译器应用以下规则来确定两个 ICSes 中哪一个更好:
-
标准 ICS 比用户定义的 ICS 更好。
-
类别更好的 ICS 比类别更差的 ICS 更好。
-
作为另一个 ICS 的真子集的 ICS 更好。
-
如果一个用户定义的 ICS1 比另一个用户定义的 ICS2 具有相同的用户转换,并且 ICS1 中的第二个标准转换优于 ICS2 中的第二个标准转换,则 ICS 1 优于 ICS 2。
-
限制较少的类型比限制较多的类型更好。这意味着目标类型为 ?? 的 ICS 比目标类型为 ?? 的 ICS 更好,如果 ?? 和 ?? 具有相同的基本类型,但 ?? 是 const 而 ?? 不是。
-
标准转换序列 ICS1 比 ICS2 更好,如果它们具有相同的等级,但是
-
ICS1 将指针转换为
bool
。 -
ICS1 和 ICS2 将指针转换为通过继承相关的类,ICS1 是一个“更小”的转换。较小的转换是跳过较少中间基类的转换。举个例子,如果
A
是从B
派生出来的,B
是从C
派生出来的,那么把B*
转换成C*
比把A*
转换成C*
好,把C*
转换成void*
比把A*
转换成void*
好。
-
列表初始化
一个复杂的情况是函数参数可能没有类型,因为它不是表达式。相反,参数是一个用花括号括起来的值列表,例如用于通用初始化的花括号括起来的列表。编译器有一些特殊的规则来决定一个列表的转换顺序。
如果参数类型是一个具有构造器的类,该构造器采用类型为std::initializer_list<T>
的单个参数,并且大括号括起来的列表中的每个成员都可以转换为T
,编译器会将该参数视为用户定义的到std::initializer_list<T>
的转换。例如,所有的容器类都有这样的构造器。
否则,编译器会尝试为该参数类型找到一个构造器,使得大括号括起来的列表中的每个元素都是该构造器的一个参数。如果成功,编译器认为该列表是用户定义的到参数类型的转换。请注意,每个构造器参数都允许另一个用户定义的转换序列。
编译器认为std::initializer_list
初始化比其他构造器列表初始化更好。这就是为什么std::string{42, 'x'}
不调用std::string(42, 'x')
构造器的原因:编译器更喜欢将{42, 'x'}
视为std::initializer_list
,这将产生一个包含两个字符的字符串,一个包含代码点 42 和字母 x ,而不是创建包含 42 个重复字母 x 的字符串的构造器。
如果参数类型不是类,并且大括号括起来的列表包含单个元素,则编译器会从大括号中解开值,并应用由括起来的值产生的普通 ICS。
决胜局
如果编译器找不到一个比其他函数排名更高的函数,它会应用一些最终规则来选择一个胜出者。编译器按顺序检查下列规则。如果一个规则产生一个获胜者,编译器就在那个点停止,并使用获胜的函数。否则,它将继续下一个加时赛:
-
尽管返回类型不被视为重载决策的一部分,但如果重载的函数调用用于用户定义的初始化,则调用更好的标准转换序列的函数返回类型将胜出。
-
非模板函数胜过函数模板。
-
更特化的函数模板胜过不那么特化的函数模板。(引用或指针模板参数比非引用或非指针参数更加特化。一个
const
参数比非const
参数更加特化。) -
否则,编译器会报告一个模糊错误。
清单 72-4 展示了一些重载的例子以及 C++ 如何对函数进行排序。
import <iostream>;
import <string>;
void print(std::string_view str) { std::cout << str; }
void print(int x) { std::cout << "int: " << x; }
void print(double x) { std::cout << "double: " << x; }
class base {
public:
void print(std::string_view str) const { ::print(str); ::print("\n"); }
void print(std::string_view s1, std::string_view s2)
{
print(s1); print(s2);
}
};
class convert : public base {
public:
convert() { print("convert()"); }
convert(double) { print("convert(double)"); }
operator int() const { print("convert::operator int()"); return 42; }
operator float() const { print("convert::operator float()"); return 3.14159f; }
};
class demo : public base {
public:
demo(int) { print("demo(int)"); }
demo(long) { print("demo(long)"); }
demo(convert) { print("demo(convert)"); }
demo(int, int) { print("demo(int, int)"); }
};
class other {
public:
other() { std::cout << "other::other()\n"; }
other(int,int) { std::cout << "other::other(int, int)\n"; }
operator convert() const
{
std::cout << "other::operator convert()\n"; return convert();
}
};
int operator+(demo const&, demo const&)
{
print("operator+(demo,demo)\n"); return 42;
}
int operator+(int, demo const&) { print("operator+(int,demo)\n"); return 42; }
int main()
{
other x{};
demo d{x};
3L + d;
short s{2};
d + s;
}
Listing 72-4.Ranking Functions for Overload Resolution
你期望清单 69-4 中的程序输出什么?
大多数时候,常识性的规则可以帮助你理解 C++ 是如何解决重载的。然而,有时您会发现编译器报告了一个您并不期望的歧义。其他时候,当您期望编译器成功时,它却无法解析重载。真正糟糕的情况是,当你犯了一个错误,编译器能够找到一个独特的函数,但这个函数与你期望的不同。您的测试失败了,但是在读取代码时,您找错了地方,因为您期望编译器抱怨糟糕的代码。
有时,你的编译器会帮助你识别那些排名最好的函数。然而,有时你可能不得不坐下来仔细检查规则,找出编译器不满意的原因。为了帮助你为那天做好准备,清单 72-5 给出了一些重载错误。看看你能否找到并解决问题。
import <iostream>;
import <string>;
void easy(long) {}
void easy(double) {}
void call_easy() {
easy(42);
}
void pointer(double*) {}
void pointer(void*) {}
const int zero = 0;
void call_pointer() {
pointer(&zero);
}
int add(int a) { return a; }
int add(int a, int b) { return a + b; }
int add(int a, int b, int c) { return a + b + c; }
int add(int a, int b, int c, int d) { return a + b + c + d; }
int add(int a, int b, int c, int d, int e) { return a + b + c + d + e; }
void call_add() {
add(1, 2, 3L, 4.0);
}
void ref(int const&) {}
void ref(int) {}
void call_ref() {
int x;
ref(x);
}
class base {};
class derived : public base {};
class sibling : public base {};
class most_derived : public derived {};
void tree(derived&, sibling&) {}
void tree(most_derived&, base&) {}
void call_tree() {
sibling s;
most_derived md;
tree(md, s);
}
Listing 72-5.Fix the Overloading Errors
easy()
的参数是一个int
,但是重载是针对long
和double
的。这两种转换都有转换秩,并且没有一个比另一个更好,因此编译器会发出一个模糊错误。
pointer()
的问题是这两种超载都不可行。如果zero
不是const
,转换成void*
将是唯一可行的选择。
add()
函数有所有的int
参数,但是一个参数是long
,另一个是double
。没问题,编译器可以把long
转换成int
,把double
转换成int
。你可能不喜欢这个结果,但是它能够做到,所以它做到了。换句话说,这里的问题是编译器没有这个函数的问题。这不是一个真正的超载问题,但是如果你在工作中遇到这个问题,你可能不会这么看。
你看到第二个ref()
函数中缺少的&
了吗?编译器认为两个ref()
函数一样好。如果你声明第二个是ref(int&)
,它就成为最佳可行候选。确切原因是x
的类型是int&
,不是int
,也就是说x
是一个int
左值,一个程序可以修改的对象。这种微妙的区别以前并不重要,但是对于重载,这种区别是至关重要的。从左值到右值的转换具有等级精确匹配,但这是一个转换步骤。从int&
到int const&
的转换也完全匹配。面对两个各有一个精确匹配转换的候选者,编译器无法决定哪一个更好。将int
更改为int&
取消了转换步骤,该功能成为明确的最佳功能。
两个tree()
函数都需要一次从派生类引用到基类引用的转换,所以编译器无法决定哪一个更好。对tree
的第一次调用需要将第一个参数从most_derived&
转换为derived&
。第二个调用需要将第二个参数从sibling&
转换为base&
。
请记住,重载的目的是允许跨多种类型的单个逻辑操作,或者允许以多种方式调用单个逻辑操作(如构造字符串)。当你决定重载一个函数时,这些规则将帮助你做出正确的选择。
Tip
当您编写重载函数时,您应该确保特定函数名的每个实现都具有相同的逻辑行为。例如,当您使用一个输出操作符cout << x
时,您只需让编译器为operator<<
选择正确的重载,您不必关心本研究中列出的详细规则。所有的规则都适用,但是标准声明了一组合理的重载,这些重载与内置类型和关键库类型一起工作,比如std::string
。
默认参数
既然你认为重载是如此的复杂,以至于你永远也不想重载一个函数,我将增加另一个复杂性。C++ 让你为一个参数定义一个默认的参数,这让一个函数调用省略相应的参数。您可以为任意数量的参数定义默认参数,只要您省略最右边的参数并且不跳过任何参数。如果愿意,您可以为每个参数提供默认参数。默认参数通常很容易理解。阅读清单 72-6 中的示例。
import <iostream>;
int add(int x = 0, int y = 0)
{
return x + y;
}
int main()
{
std::cout << add() << '\n';
std::cout << add(5) << '\n';
std::cout << add(32, add(4, add(6))) << '\n';
}
Listing 72-6.Default Arguments
清单 72-6 中的程序打印什么?
不难预测结果,如下图所示:
0
5
42
默认参数提供了替代重载的捷径。例如,您可以使用一个构造器和默认参数,而不是为rational
类型编写几个构造器,如下所示:
template<class T> class rational {
public:
rational(T const& num = T{0}, T const& den = T{1})
: numerator_{num}, denominator_{den}
{
reduce();
}
...omitted for brevity...
};
我们对默认构造器的定义必须有所改变。默认构造器不是不声明参数的构造器,而是可以不带参数调用的构造器。这个rational
构造器符合这个要求。
正如您可能已经猜到的,默认参数使重载决策变得复杂。当编译器搜索重载函数时,它会检查函数调用中显式出现的每个参数,但不会根据默认的参数类型来检查默认的参数类型。因此,使用默认参数会更容易陷入不明确的情况。例如,假设您将示例rational
构造器添加到现有的类模板中,而没有删除旧的构造器。以下定义都会导致模糊错误:
rational<int> zero{};
rational<int> one{1};
默认参数有其用途,但重载通常会给你更多的控制权。例如,通过重载 rational 构造器,当我们知道分母是 1 时,我们避免调用reduce()
。使用内联函数,一个重载函数可以调用另一个重载函数,这通常完全消除了对默认参数的需要。如果你不确定是使用默认参数还是重载,我推荐重载。
虽然你可能不相信我,但我的意图并不是吓你不要重载函数。你很少会去探究超载的微妙之处。大多数时候,你可以依靠常识。但是有时候,编译器和你的常识不一致。当编译器抱怨不明确的重载或其他问题时,了解编译器的规则可以帮助您摆脱困境。
下一篇文章探讨了 C++ 编程的另一个方面,它的规则可能复杂而可怕:元编程,或者编写在编译时运行的程序。
七十三、编译时编程
C++ 提供了许多机会来编写在编译时而不是运行时运行的代码。例如,模板提供了一个独特的、功能性的编程环境,尽管它的语法很复杂。在 C++ 20 中,一些新的关键字为你提供了更精确的方法来控制编译时而不是运行时发生的事情。一些编译时编程技术被称为元编程,但是这个术语的定义并不严格,有些可能专门用于类型而不是值的编程。无论如何命名,编译时编程都是整个 C++ 编程的一个有价值的方面。
编译时函数
告诉编译器,你希望它能够在编译时用关键字constexpr
或consteval
计算一个函数。在函数的返回类型的类型说明符中使用任一关键字(但不要两个都用),就像你在本书中经常看到的那样:
consteval double square(double x) { return x * x; }
constexpr double cube(double x) { return x * x * x; }
对于consteval
函数,函数参数必须是编译时常量,编译器总是在编译时调用函数产生一个常量结果。
对于constexpr
函数,如果用编译时常量参数调用该函数,编译器在编译时调用该函数并产生一个常量结果。但是您也可以在没有常量参数的情况下调用该函数,编译器会将该函数视为普通的内联函数,并生成适当的运行时代码来产生非常量结果。
假设一个名为value
,square(3.0)
的变量在编译时变成了9.0
,而square(value)
是不允许的。在编译时调用cube(3.0)
变成27.0
,在运行时用value
变量计算cube(value)
。一个constexpr
函数在运行时被求值时是隐式的inline
。
一个constexpr
或consteval
函数有一些限制。大多数限制适用于这两种函数。函数中的返回类型、参数类型和变量类型必须是所谓的文字类型。文字类型是可以在编译时构造和销毁的类型。内置的基本类型、枚举和指针显然适合这个类。对于一个文本类,它必须有一个constexpr
析构函数和文本数据成员。被调用的构造器必须是constexpr
,尽管这个类可以有其他不是constexpr
的构造器。
一个constexpr
构造器只能调用其他constexpr
构造器,这意味着所有基类构造器都必须是constexpr
。如果一个类有一个constexpr
析构函数,那么它的所有基类都必须有constexpr
析构函数(或者根本没有析构函数使用=delete
)。
值得注意的一个区别是析构函数不能用consteval
声明。
当然,当你将一个函数声明为constexpr
时,编译器需要函数定义,所以你不能将一个constexpr
声明放在一个模块接口中,并试图将其定义隐藏在一个单独的模块实现中。定义必须进入模块接口。
编写constexpr
函数时有一个挑战。不可能向用户报告错误,例如抛出异常。例如,将所有的rational
构造器声明为constexpr
似乎是合理的,但这意味着调用reduce()
,如果分母为零,这将抛出异常。这意味着构造一个分母为零的constexpr rational
对象是非法的,但是编译器不需要发出错误消息。负担落在使用rational
的程序员身上,确保 0 永远不会被用作constexpr rational
对象的分母。注意,复制 rational
类模板的声明,并确保每一个可以成为 constexpr
的函数都是 constexpr
。我的拍摄见清单 73-1 。
export module numeric;
import <concepts>;
import <iostream>;
import <numeric>;
import <sstream>;
import <stdexcept>;
export template<class T>
requires std::integral<T>
class rational
{
public:
using value_type = T;
constexpr rational() : rational{0} {}
constexpr rational(value_type num) : numerator_{num}, denominator_{1} {}
constexpr rational(value_type num, value_type den)
: numerator_{num}, denominator_{den}
{
reduce();
}
constexpr value_type numerator() const { return numerator_; }
constexpr value_type denominator() const { return denominator_; }
constexpr rational& operator*=(rational const& rhs) {
numerator_ *= rhs.numerator_;
denominator_ *= rhs.denominator_;
reduce();
return *this;
}
constexpr rational& operator/=(rational const& rhs) {
numerator_ *= rhs.denominator_;
denominator_ *= rhs.numerator_;
reduce();
return *this;
}
... every member function can be constexpr
private:
constexpr void reduce() {
if (denominator_ == 0)
throw std::invalid_argument{"denominator is zero"};
if (denominator_ < 0)
{
denominator_ = -denominator_;
numerator_ = -numerator_;
}
auto div{std::gcd(numerator_, denominator_)};
numerator_ = numerator_ / div;
denominator_ = denominator_ / div;
}
value_type numerator_;
value_type denominator_;
};
export template<class T>
constexpr bool operator==(rational<T> const& lhs, rational<T> const& rhs)
{
return lhs.numerator() == rhs.numerator() and
lhs.denominator() == rhs.denominator();
}
// Every free function except
I/O can be constexpr
export template<class T, class Ch, class Tr>
std::basic_ostream<Ch,Tr>& operator<<(std::basic_ostream<Ch, Tr>& stream, rational<T> const& r)
{
std::basic_ostringstream<Ch,Tr> tmp;
tmp << r.numerator() << '/' << r.denominator();
return stream << tmp.str();
}
Listing 73-1.Adding constexpr Throughout the rational Class Template
编译时变量
constexpr
说明符也适用于命名对象。一个constexpr
对象是隐式的const
。它必须有一个只调用constexpr
函数或constexpr
构造器的初始化器。
您还可以用constinit
关键字初始化一个编译时静态变量。变量必须有静态生存期,即在名称空间范围内或用static
关键字声明。编译器总是确保这些变量在main()
开始执行之前被初始化。(或者,对于在函数内部定义的static
变量,在函数第一次被调用之前。)但是如果一个静态变量的初始值依赖于另一个变量的值,那么先初始化哪个变量是不确定的。通过用constinit
声明一个静态变量,编译器在编译时确定这个值,并使用这个固定值来初始化变量,所有依赖于它的变量都会看到这个常量值。constinit
与其他const
相关关键字的关键区别在于constinit
仅适用于初始化。对象不一定是const
。清单 73-2 展示了constexpr
和constinit
对象的一些用法。
import <iostream>;
import rational;
constinit rational<long> r{355, 113};
constinit rational<long> const q{31416, 10000};
int main()
{
constexpr rational<long> p{2};
r /= q;
r *= p;
std::cout << r << '\n';
}
Listing 73-2.Adding constexpr Throughout the rational Class Template
可变长度模板参数列表
您可以定义一个接受任意数量模板参数的模板(称为可变模板)。这种能力并不特别与编译时编程相关,但它是高级的,属于书的末尾。许多编译时编程习惯用法都涉及可变长度模板,因此在这里包含这个主题似乎是合适的。
可变长度模板的许多用途是针对库作者的,但这并不意味着其他人不能加入进来。要声明一个可以接受任意数量参数的模板参数,请在类型模板参数的关键字class
或typename
后,或者在值模板参数的类型后使用省略号。这样的参数被称为参数包。以下是一些简单的例子:
template<class... Ts> struct List {};
template<int... Ns> struct Numbers {};
用任意数量的模板参数实例化模板:
using int_type = List<int>;
using Char_types = List<char, unsigned char, signed char>;
using One_two_three = Numbers<1, 2, 3>;
您还可以声明一个函数参数包,这样函数就可以接受任意数量的任意类型的参数,如下所示:
template<class... Types>
void list(Types... args);
当您调用函数时,参数包包含每个函数参数的类型。在下面的例子中,编译器隐式地确定Types
模板参数是<int, char, std::string>
。
list(1, 'x', std::string{"yz"});
sizeof...
操作符返回参数包中元素的数量。例如,您可以定义一个Size
模板来计算参数包中的参数数量,如下所示:
template<class... Ts>
struct Size { constexpr static std::size_t value = sizeof...(Ts); };
static_assert(Size<int, char, long>::value == 3);
static_assert
声明检查编译时布尔表达式,如果条件为假,则导致编译器错误。您可以向static_assert()
添加第二个参数来给出一个有用的消息。通常仅仅看到表情就足够了。在编译时能检测到的问题越多越好。
要使用参数包,通常用一个后跟省略号的模式来扩展它。模式可以是参数名、使用参数的类型、使用函数参数包的表达式等等。
清单 73-3 显示了一个print()
函数,它接受一个流,后面跟有任意数量的任意类型的参数。它通过扩展参数包来打印每个值。std::forward()
函数将一个值转发给一个函数,而不修改或复制它(称为“完美转发”)。对于rest
中的每个参数r
,编译器将包表达式std::forward<Types>(rest)...
扩展为std::forward(r)
。通过到处传递右值引用并使用std::forward()
,print()
函数可以以最小的开销传递对其参数的引用。请注意,这里没有对参数包的大小进行测试。包在编译时被扩展,当包为空时,一个重载函数结束扩展。
import <iostream>;
import <utility>;
// Forward declaration.
template<class... Args>
void print(std::ostream& stream, Args&&...);
// Print the first value in the list, then recursively
// call print() to print the rest of the list.
template<class T, class... Args>
void print_split(std::ostream& stream, T&& head, Args&& ... rest)
{
stream << head << ' ';
print(stream, std::forward<Args>(rest)...);
}
// End recursion when there are no more values to print.
void print_split(std::ostream&)
{}
// Print an arbitrary list of values to a stream.
template<class... Args>
void print(std::ostream& stream, Args&&... args)
{
print_split(stream, std::forward<Args>(args)...);
}
int main()
{
print(std::cout, 42, 'x', "hello", 3.14159, 0, '\n');
}
Listing 73-3.Using a Function Parameter Pack to Print Arbitrary Values
用户定义的文字
编译时编程的一个常见用途是定义自己的文字。标准库将诸如"view"sv
这样的文字定义为std::string_view{"view"}
的快捷方式。用户定义的文字总是以下划线开头,以避免与标准库中现有或未来的文字冲突。
将文字定义为operator""
,后跟文字名称。您可以定义字符、数字或字符串文字。编译器寻找将该值作为参数的函数,或者将组成该值的字符作为模板参数包。清单 73-4 显示了_rev
文字,它对整数进行操作以反转位。
consteval unsigned long long operator"" _rev(unsigned long long value)
{
unsigned long long reversed{0};
for (std::size_t i{std::numeric_limits<unsigned long long>::digits}; i > 0; --i)
{
auto bit{ value & 1 };
value >>= 1;
reversed = (reversed << 1) | bit;
}
return reversed;
}
static_assert(0_rev == 0);
static_assert(0x1234567890abcdef_rev == 0xf7b3d5091e6a2c48ULL);
Listing 73-4.Defining a Literal Operator to Reverse Bits in an Integer
用户定义的文本的模板形式尤其难以编写,因为编译器传递的是用户编写的精确形式,这意味着您的操作符必须解释带有撇号的二进制、八进制、十进制和十六进制数字(0b1011'0010_rev
、0373_rev
、179_rev
和0xb3_rev
都是相同的值)。让编译器解析数字要容易得多。
作为值的类型
有了constexpr
函数,带值的元编程变得更加容易,但是很多元编程涉及类型,这需要完全不同的观点。当使用类型进行元编程时,类型承担值的角色。没有办法定义变量,只有模板参数,所以你设计模板来声明你需要存储类型信息的模板参数。元程序中的“函数”(有时称为元函数)只是另一个模板,所以它的参数是模板参数。
例如,标准库包含元函数is_same
(在<type_traits>
中定义)。这个模板接受两个模板参数,并产生一个类型作为结果。标准库中的元函数返回带有类成员的结果。如果结果是一个类型,则成员 typedef 被称为type
。像is_same
这样的谓词的type
成员是一个元编程布尔值。如果两个参数类型相同,则结果为std::true_type
(也在<type_traits>
中定义)。如果参数类型不同,结果是std::false_type
。
因为true_type
和false_type
本身就是元编程类型,所以它们也有类型成员 typedefs。true_type::type
的值是true_type
;同上false_type
。有时元程序必须将元编程值视为实际值。因此,表示值的元编程类型有一个名为value
的静态数据成员。如你所料,true_type::value
就是true
,false_type::value
就是false
。
你会怎么写 is_same ?你必须声明成员类型定义type
到std::true_type
或者std::false_type
,这取决于模板参数。一个简单的方法是,根据模板参数,从true_type
或false_type
派生is_same
,同时获得便利的value
静态数据成员。这是部分特化的直接实现,如清单 73-5 所示。
template<class T, class U>
struct is_same : std::false_type {};
template<class T>
struct is_same<T, T> : std::true_type {};
Listing 73-5.Implementing the is_same Metafunction
让我们写另一个元函数,一个标准库中没有的。这个叫promote
。它接受单个模板参数,如果模板参数是bool
、short
、char
,则生成int
,否则生成参数本身。换句话说,它实现了 C++ 整数提升规则的简化子集。你会怎么写推广?这次结果是纯类型,所以没有value
成员。最简单的方法是最直接的。清单 73-6 显示了一种可能性。
template<class T> struct promote { typedef T type; };
template<> struct promote<bool> { typedef int type; };
template<> struct promote<char> { typedef int type; };
template<> struct promote<signed char> { typedef int type; };
template<> struct promote<unsigned char> { typedef int type; };
template<> struct promote<short> { typedef int type; };
template<> struct promote<unsigned short> { typedef int type; };
Listing 73-6.One Implementation of the promote Metafunction
实现promote
的另一种方法是使用模板参数包。假设您有一个元函数is_member
,它测试它的第一个参数,以确定它是否出现在由其余参数组成的参数包中。即is_member<int, char>
是false_type
,而is_member<int, short, int
、long>
产生true_type
。给定is_member
,你会如何实现promote
?清单 73-7 展示了一种方法,对is_member
的结果使用部分特化。
// Primary template when IsMember=std::true_type, that is, T is in the
// list of types to promote to int.
template<class IsMember, class T>
struct get_member {
using type = int;
};
// false means T is not in the list, so leave the type alone.
template<class T>
struct get_member<std::false_type, T>
{
using type = T;
};
template<class T>
struct promote {
using type = get_member<typename is_member<T,
bool, unsigned char, signed char, char, unsigned short, short>::type, T>::type;
};
Listing 73-7.Another Implementation of the promote Metafunction
记住,当命名一个依赖于模板参数的类型时,typename
是必需的。元函数的type
成员当然有资格作为依赖类型名。这个实现使用部分特化来确定来自is_member
的结果。使用is_member
实现promote
可能看起来更复杂,但是如果类型列表很长,或者可能随着应用程序的发展而增长,那么is_member
方法似乎更有吸引力。虽然使用is_member
很容易,但是写起来就没那么容易了。还记得清单 73-4 是如何从功能包的头部分离出来的吗?用同样的技术拆分参数包,也就是写一个 helper 类,有一个Head
模板参数和一个Rest
模板参数包。清单 73-8 展示了实现is_member
的一种方式。
template<class Check, class... Args> struct is_member;
// Helper metafunction to separate Args into Head, Rest
template<class Check, class Head, class... Rest>
struct is_member_helper :
std::conditional<std::is_same<Check, Head>::value,
std::true_type,
is_member<Check, Rest...>>::type
{};
// Partial specialization for empty Args
template<class Check, class Head>
struct is_member_helper<Check, Head> : std::is_same<Check, Head>::type {};
/// Test whether Check is the same type as a type in Args.
template<class Check, class... Args>
struct is_member : is_member_helper<Check, Args...> {};
Listing 73-8.Implementing the is_member Metafunction
清单 73-8 没有编写专门针对std::false_type
的定制元函数,而是使用了标准元函数std::conditional
。尽可能使用标准库通常更好,你可以重写清单 73-7 来使用std::conditional
。为了帮助您理解这个重要的元功能,下一节将深入讨论std::conditional
。
条件类型
元编程的一个关键方面是在编译时做出决策。为此,您需要一个条件运算符。C++ 在不同的环境中提供了几种不同的方法来实现这一点。例如,在函数内部,可以使用if constexpr
。在模板定义中,您可能能够使用约束。标准库在<type_traits>
头中提供了两种风格的条件。
要测试一个条件,使用std::conditional<Condition, IfTrue, IfFalse>::type
。Condition
是一个bool
值,IfType
和IfFalse
是类型。如果Condition
为真,则type
成员是IfTrue
的 typedef,如果Condition
为假,则IfFalse
的 typedef。
尝试编写自己的 std::conditional 实现。你的标准库可能不同,但不会与我在清单 73-9 中的解决方案有太大的不同。
template<bool Condition, class IfTrue, class IfFalse>
struct conditional
{
using type = IfFalse;
};
template<class IfTrue, class IfFalse>
struct conditional<true, IfTrue, IfFalse>
{
using type = IfTrue;
};
Listing 73-9.One Way to Implement std::conditional
看待std::conditional
的另一种方式是将它视为两种类型的数组,由一个bool
值索引。由整数索引的类型数组呢?标准库没有这样的模板,但是你可以写一个。使用模板参数包和整数选择器。如果选择器无效,不要定义type
成员 typedef。例如,choice<2, int, long, char, float, double>::type
会是char
,而choice<2, int, long>
不会声明一个type
成员。试写选择。同样,您可能需要两个相互递归的类。一个类从参数包中去掉第一个模板参数,并递减索引。模板特化终止了递归。将您的解决方案与清单 73-10 中的我的解决方案进行比较。
import <type_traits>;
// forward declaration
template<std::size_t, class...>
struct choice;
// Default: subtract one, drop the head of the list, and recurse.
template<std::size_t N, class T, class... Types>
struct choice_split {
using type = choice<N-1, Types...>::type;
};
// Index 0: pick the first type in the list.
template<class T, class... Ts>
struct choice_split<0, T, Ts...> {
using type = T;
};
// Define type member as the N-th type in Types.
template<std::size_t N, class... Types>
struct choice {
using type = choice_split<N, Types...>::type;
};
// N is out of bounds
template<std::size_t N>
struct choice<N> {};
// Tests
static_assert(std::is_same<int,
typename choice<0, int, long, char>::type>::value, "error in choice<0>");
static_assert(std::is_same<long,
typename choice<1, int, long, char>::type>::value, "error in choice<1>");
static_assert(std::is_same<char,
typename choice<2, int, long, char>::type>::value, "error in choice<2>");
Listing 73-10.Implementing an Integer-Keyed Type Choice
使用新的choice
模板从众多选项中选择一个。在一个项目中,我为安全性和性能的不同权衡定义了三种风格的迭代器。快速迭代器尽可能快地工作,没有安全检查。安全迭代器将进行足够的检查以避免未定义的行为。迂腐的迭代器用于调试和检查一切可能的东西,不考虑速度。我可以通过将ITERATOR_TYPE
定义为 0、1 或 2 来选择我想要的迭代器样式,例如:
using iterator = choice<ITERATOR_TYPE,
pedantic_iterator, safe_iterator, fast_iterator>::type;
替换失败不是错误(SFINAE)
由 Daveed Vandevoorde 引入的一种编程技术被称为 SFINAE(发音为 ess-finn-ee),因为替换失败不是错误。简而言之,如果编译器试图实例化一个无效的模板函数,编译器不认为这是一个错误,而只是在解决重载时忽略该实例化。在 C++ 20 中引入约束之前,这个概念很普遍,所以您至少需要能够阅读使用 SFINAE 的代码。使用约束的新代码更容易阅读和维护。
举个例子,假设你正在用某种数据编码写数据,比如 ASN.1 BER,XDR,JSON 等等。编码的细节对于本练习并不重要。重要的是,我们希望对所有整数和所有浮点数一视同仁,但对整数和浮点数的处理方式不同。也就是说,我们希望使用模板来减少重复编码的数量,但是我们希望某些类型有不同的实现。我们不能部分特化函数,所以我们必须使用重载。
问题是如何声明三个名为encode
的模板函数,这样一个是任何整数的模板函数,另一个是任何浮点类型的模板函数,还有一个是字符串的模板函数。
一种方法是为最大整数类型和最大浮点类型声明重载。编译器会将实际类型转换成更大的类型。这很容易实现,但会产生运行时成本,这在某些环境中可能很大。我们需要一个更好的解决方案。
与std::conditional
类似,std::enable_if
接受一个布尔值和一个 if-true 类型。与std::conditional
不同,它没有 if-false 分支。相反,没有定义类型成员。当编译器查找类型成员时,如果找不到某个模板参数的类型成员,就会触发 SFINAE,从而丢弃该参数的函数签名。
使用enable_if
,可以声明重载的encode
函数,但是只有当is_integral
为真时才启用一个函数,另一个函数用于浮点类型,等等。目标不是禁用encode()
函数,而是指导编译器解决重载问题。
<type_traits>
头有几个自省特征。每种类型都分为类、枚举、整数、浮点等等。这本书不是关于二进制数据编码的,所以这个例子的内容将把文本写到流中,但是它将用来说明如何使用enable_if
。
超载的正常规则仍然适用。也就是说,不同的函数必须有不同的参数。所以对返回类型使用enable_if
没有帮助。这一次,enable_if
将被用作encode
的另一个参数,但是有一个默认值,对调用者隐藏它。(注意,使用enable_if
作为函数的主参数是行不通的,因为它破坏了编译器从函数的参数类型推断模板类型的能力。)具体来说,enable_if
参数被做成指针类型,用nullptr
作为默认值,以确保没有额外的代码来构造或传递这个额外的参数。有了内联,编译器甚至可以优化掉额外的参数,所以没有运行时损失。清单 73-11 展示了解决这个问题的一种方法。
import <iostream>;
import <type_traits>;
template<class T>
void encode(std::ostream& stream, T const& int_value,
typename std::enable_if<std::is_integral<T>::value, T>::type* = nullptr)
{
// All integer types end up here.
stream << "int: " << int_value << '\n';
}
template<class T>
void encode(std::ostream& stream, T const& enum_value,
typename std::enable_if<std::is_enum<T>::value>::type* = nullptr)
{
// All enumerated types end up here.
// Record the underlying integer value.
stream << "enum: " <<
static_cast<typename std::underlying_type<T>::type>(enum_value) << '\n';
}
template<class T>
void encode(std::ostream& stream, T const& float_value,
typename std::enable_if<std::is_floating_point<T>::value>::type* = nullptr)
{
// All floating-point types end up here.
stream << "float: " << float_value << '\n';
}
// enable_if forms cooperate with normal overloading
void encode(std::ostream& stream, std::string const& string_value)
{
stream << "str: " << string_value << '\n';
}
int main()
{
encode(std::cout, 1);
enum class color { red, green, blue };
encode(std::cout, color::blue);
encode(std::cout, 3.0);
encode(std::cout, std::string("string"));
}
Listing 73-11Using enable_if to Direct Overload Resolution
您对 C++ 20 的探索到此结束。下一个也是最后一个探索是一个顶点项目,将你所学的一切整合起来。我希望你能享受你的旅程,并计划更多的旅行来完成你对这门语言的理解和掌握。
七十四、项目 4:计算器
现在是时候通过编写一个简单的文本计算器来应用你在本书中学到的一切了。例如,如果您键入1 + 2
,计算器将打印3
。这个项目可以像你希望或敢于做的那样复杂。我建议从小处着手,慢慢增加功能:
-
从一个简单的解析器开始,读取数字和操作符。如果您熟悉一个解析器生成器,比如 Bison 或 ANTLR,那就使用它吧。如果你喜欢冒险,试着了解一下 Spirit,这是 Boost 项目的一部分。Spirit 利用 C++ 操作符重载来实现类似 BNF 的语法,以便用 C++ 编写解析器,而不需要额外的工具。如果不想涉及其他工具或库,我推荐一个简单的类似 LISP 的语法,这样就不用把所有时间都花在解析器上了。本书网站上的代码实现了一个简单的递归下降解析器。首先实现基本算术运算符:
+
、-
、*
和/
。所有数字都使用 double。被零除的时候做点有帮助的事。 -
添加变量和
=
运算符。用一些有用的常量初始化计算器,比如pi
。 -
向前的一大步不是在输入表达式时评估每个表达式,而是创建一个解析树。这需要在解析器上做一些工作,更不用说添加解析树类,即表示表达式、变量和值的类。
-
给定变量和解析树,定义函数和调用用户自定义函数是一个较小的步骤。
-
最后,添加将函数保存到文件中的功能,并从文件中加载它们。现在你可以创建有用的函数库。
-
如果你真的雄心勃勃,尝试支持多种类型。使用 pimpl 习语(探索 65 )来定义一个
number
类和一个number_impl
类。让计算器使用number
类,这样它就从number_impl
类中解放出来了。为您想要支持的类型实现派生类:integer、double、rational 等等。
正如你所看到的,只要你愿意,这种项目可以继续下去。总会有新的功能添加进来。只是要确保以小增量添加功能。
同样,你的 C++ 专业知识之旅永远不会结束。总会有新的惊喜——在你的下一个项目中,下一次编译器升级就在眼前。在我写这篇文章的时候,标准化委员会已经完成了 C++ 20 的工作,并且已经开始了 C++ 23 的工作。之后将是下一个语言修订周期,下一个,再下一个。
我祝你一路顺风,也希望你享受即将到来的探险。