探索 C++20(三)
十九、大小写
继续我们在探索 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++ 如何处理浮点值。