探索 C++20(二)
九、数组和向量
既然你已经了解了基础知识,是时候开始迎接更激动人心的挑战了。让我们写一个真正的程序,一些不平凡的,但仍然足够简单,以掌握这本书的早期。您的工作是编写一个程序,从标准输入中读取整数,将它们按升序排序,然后打印排序后的数字,每行一个。
在这一点上,这本书还没有涵盖足够的材料来帮助你解决这个问题,但是思考这个问题和解决它可能需要的工具是有启发性的。在这个探索中,你的第一个任务是为程序编写伪代码。尽可能地编写 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 ,你可以从这本书的网站下载。注意单词是如何左对齐的,计数是如何右对齐的。我们期望数字是右对齐的,而单词习惯上是左对齐的(在西方文化中)。还记得探险中的constexpr8 吗?这仅仅意味着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不同。事实证明,这个问题比它第一次出现时要复杂得多,所以它值得一个完整的探索。