std::cin和处理无效输入
本节阅读量:
大多数具有某种用户界面的程序都需要处理用户输入。在目前编写的程序中,我们一直使用std::cin来要求用户输入文本。由于文本输入非常自由(用户可以输入任何内容),用户很容易输入不符合预期的内容。
编写程序时,应该始终考虑用户会如何(无意中或以其他方式)误用程序。一个编写良好的程序会预判用户可能如何误用它,并优雅地处理这些情况,或者从一开始就防止它们发生(如果可能)。一个能够很好处理错误情况的程序被认为是健壮的。
本课将专门研究用户通过std::cin输入无效文本的方式,并展示几种处理这些情况的方法。
std::cin、缓冲区和提取
为了讨论std::cin和operator»是如何失败的,首先需要了解它们是如何工作的。
当我们使用操作符»获取用户输入并将其放入变量时,这称为“提取”。在该上下文中,»操作符相应地称为提取操作符。
当用户响应提取操作并输入内容时,这些数据会被放入std::cin内部的缓冲区。缓冲区(也称为数据缓冲区)只是一块内存,用于在数据从一个位置移动到另一个位置时临时存储数据。在这种情况下,缓冲区用于保存用户输入,并等待将其提取到变量中。
使用提取操作符时,会执行以下过程:
- 如果输入缓冲区中已经存在数据,则使用该数据进行提取。
- 如果输入缓冲区不包含数据,则要求用户输入数据以进行提取(大多数情况下都是这样)。当用户点击enter时,将在输入缓冲区中放置“\n”字符。
- 操作符»将尽可能多的数据从输入缓冲区提取到变量中(忽略任何前导空格字符,如空格、制表符或“\n”)。
- 无法提取的任何数据都留在输入缓冲区中,供下一次提取使用。
如果从输入缓冲区提取了至少一个字符,则提取成功。任何未提取的输入都会保留在输入缓冲区中,以供后续提取。例如:
1
2
|
int x{};
std::cin >> x;
|
如果用户键入5a,然后单击enter,则将提取5,将其转换为整数,并分配给变量x。“a\n"将在输入缓冲区中保留,以供下次提取。
如果输入数据与要提取到的变量类型不匹配,则提取失败。例如:
1
2
|
int x{};
std::cin >> x;
|
如果用户要输入“b”,则提取将失败,因为“b”无法提取为整数变量。
验证输入
检查用户输入是否符合程序期望的过程称为输入验证。
有三种基本的输入验证方法:
用户边输入边校验:
- 阻止用户输入无效的字符。
用户输入完成后再校验:
- 将用户输入的所有内容保存到字符串中,然后验证字符串是否有效。如果有效,再将字符串转换成最终格式。
- 让用户任意输入,使用std::cin和operator»提取数据,同时处理提取失败的情形。
一些图形用户界面和高级文本界面允许您在用户输入时验证输入(逐个字符)。一般来说,程序员会提供一个验证函数,该函数接收用户目前为止的输入,如果输入有效则返回true,否则返回false。每次用户按下按键时都会调用此函数。如果验证函数返回true,则接受用户刚才按下的键。如果验证函数返回false,则用户刚才输入的字符会被丢弃(并且不会显示在屏幕上)。使用此方法,可以确保用户输入的任何内容都是有效的,因为任何无效按键都会被发现并立即丢弃。不幸的是,std::cin不支持这种类型的验证。
由于字符串对可输入字符没有任何限制,如果使用operator»将输入提取到字符串中,提取通常会成功(请记住,std::cin会跳过前导空白字符,并在遇到下一个空白字符时停止提取)。一旦提取了字符串,程序就可以解析该字符串,判断它是否有效。然而,解析字符串并将字符串输入转换为其他类型(例如数字)可能比较有挑战性,所以这种方式只在少数情况下使用。
最常见的做法是让std::cin和提取操作符来完成困难的工作。在这种方法下,我们允许用户输入任何内容,让std::cin和操作符»尝试提取它,并在提取失败时处理后果。这是最简单的方法,也是下面将详细讨论的方法。
示例程序
考虑以下没有错误处理的计算器程序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
#include <iostream>
double getDouble()
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
return x;
}
char getOperator()
{
std::cout << "Enter one of the following: +, -, *, or /: ";
char op{};
std::cin >> op;
return op;
}
void printResult(double x, char operation, double y)
{
switch (operation)
{
case '+':
std::cout << x << " + " << y << " is " << x + y << '\n';
break;
case '-':
std::cout << x << " - " << y << " is " << x - y << '\n';
break;
case '*':
std::cout << x << " * " << y << " is " << x * y << '\n';
break;
case '/':
std::cout << x << " / " << y << " is " << x / y << '\n';
break;
}
}
int main()
{
double x{ getDouble() };
char operation{ getOperator() };
double y{ getDouble() };
printResult(x, operation, y);
return 0;
}
|
这个简单的程序要求用户输入两个数字和一个数学运算符。
现在,考虑无效用户输入可能会破坏该程序的哪些地方?
首先,我们要求用户输入一些数字。如果他们输入数字以外的内容(例如“q”)会怎么样?在这种情况下,提取将失败。
其次,我们要求用户输入四个可能的符号之一。如果他们输入的字符不是我们期望的符号之一,该怎么办?我们能够提取输入,但程序没有处理输入错误符号的情形。
第三,我们要求用户输入符号,而他们输入类似“qhello”的字符串,该怎么办?尽管我们可以提取所需的“”字符,但缓冲区中还会留下额外输入,这会在之后导致问题。
无效文本输入的类型
通常可以将文本输入错误分为四种类型:
- 输入提取成功,但输入对程序没有意义(例如,输入“k”作为数学运算符)。
- 输入提取成功,但用户后续输入了其他输入(例如,输入“*q hello”作为数学运算符)。
- 输入提取失败(例如,尝试在数字输入中输入“q”)。
- 输入提取成功,但输入数值发生了溢出。
因此,为了使程序健壮,每当要求用户输入时,理想情况下都应该确定上述每一种情况是否可能发生,并编写代码来处理这些情况。
让我们深入研究每一种情况,以及如何使用std::cin处理它们。
错误情况1:提取成功,但输入无意义
这是最简单的情况。考虑上述程序的以下执行:
1
2
3
|
Enter a decimal number: 5
Enter one of the following: +, -, *, or /: k
Enter a decimal number: 7
|
在这种情况下,要求用户输入四个符号中的一个,但他们输入的是“k”,k是一个有效字符,因此std::cin将其提取到变量op中,并将其返回给main。但程序并没有预料到这种情况会发生,因此它没有正确地处理这种情况(因此从不输出任何内容)。
这里的解决方案很简单:进行输入验证。这通常包括3部分:
- 检查用户的输入是否是我们所期望的
- 如果是的话,执行后续流程
- 如果不是,提示用户,并让用户进行重试
下面是一个新的getOperator()函数,用来验证用户输入。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
char getOperator()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter one of the following: +, -, *, or /: ";
char operation{};
std::cin >> operation;
// 检查用户输入是否有效
switch (operation)
{
case '+':
case '-':
case '*':
case '/':
return operation; // 将有效的输入返回
default: // 否则提示用户输入有误
std::cout << "Oops, that input is invalid. Please try again.\n";
}
}
}
|
如上所示,使用while无限循环,直到用户提供有效输入。如果输入有误,就要求用户重试,直到他们提供有效输入、关闭程序或关闭计算机。
错误情况2:提取成功,但有多余的输入
考虑上述程序的以下执行:
1
|
Enter a decimal number: 5*7
|
你认为接下来会发生什么?
1
2
|
Enter a decimal number: 5*7
Enter one of the following: +, -, *, or /: Enter a decimal number: 5 * 7 is 35
|
程序打印了正确的答案,但格式完全混乱。让我们仔细看看原因。
当用户输入5*7作为输入时,该输入进入缓冲区。然后操作符»将5提取到变量x,并将”*7\n"留在缓冲区中。接下来,程序打印"Enter one of the following: +, -, *, or /:"。然而,当调用提取操作符时,它看到"*7\n"正在缓冲区中等待提取,因此会使用这些已有输入,而不是要求用户提供更多输入。然后,它提取“*”字符,并在缓冲区中保留"7\n"。
在要求用户输入另一个十进制数后,直接从缓冲区中提取7。由于用户从未有机会输入额外的数据并按enter键(换行),因此输出提示都在同一行上一起运行。
尽管上面的程序可以工作,但执行过程很混乱。更好的办法是简单地忽略输入中的任何无关字符。幸运的是,忽略字符很容易:
1
|
std::cin.ignore(100, '\n'); // 清空缓存中的100个字符,或者直到一个 '\n' 被清除
|
这个调用最多会删除100个字符,但如果用户输入的字符超过100个,我们仍然会得到混乱的输出。要忽略下一个“\n”之前的所有字符,可以将std::numeric_limits<std::streamsize>::max()传递给std::cin.ignore()。std::numeric_limits<std::streamsize>::max()返回可以存储在std::streamsize类型变量中的最大值。将该值传递给std::cin.ignore()会导致std::cin清空所有缓存。使用之前需要先“#include <limits>”。
要忽略直到并包括下一个“\n”字符的所有内容,可以调用:
1
|
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
这一行代码对于它所做的事情来说相当长,所以将它包装在一个可以代替std::cin.ignore()调用的函数中很方便。
1
2
3
4
5
6
|
#include <limits> // for std::numeric_limits
void ignoreLine()
{
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
|
由于用户输入的最后一个字符通常是“\n”,因此我们可以告诉std::cin忽略缓冲字符,直到它找到换行符(“\n”也被删除)。
现在更新getDouble()函数,使其忽略任何无关输入:
1
2
3
4
5
6
7
8
9
|
double getDouble()
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
ignoreLine();
return x;
}
|
现在,即使第一个输入是“5*7”,程序也会按预期工作——5会被提取,其余字符会从输入缓冲区中删除。由于输入缓冲区现在为空,因此下次执行提取操作时会正确要求用户输入!
提示
在某些情况下,最好将无关的输入视为故障情况(而不是忽略它)。然后,我们可以要求用户重新输入。
为了做到这一点,我们需要某种方法来确定在成功提取后输入流中是否还有任何输入。我们可以使用std::cin.peek()函数,它允许查看输入流中的下一个字符,而不提取它。
下面是getDouble()的一个变体,它会在用户输入任何无关内容时要求重新输入:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
double getDouble()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
// 如果有额外的输入, 当做用户输入失败
if (!std::cin.eof() && std::cin.peek() != '\n')
{
ignoreLine(); // 移除缓冲区内的所有字符
continue;
}
ignoreLine();
return x;
}
}
|
错误情况3:提取失败
现在考虑以下执行:
1
|
Enter a decimal number: a
|
程序没有按预期执行并不令人惊讶,但它失败的方式很有意思:
1
2
3
4
|
Enter a decimal number: a
Enter one of the following: +, -, *, or /: Oops, that input is invalid. Please try again.
Enter one of the following: +, -, *, or /: Oops, that input is invalid. Please try again.
Enter one of the following: +, -, *, or /: Oops, that input is invalid. Please try again.
|
最后一行持续打印,直到程序关闭。
这看起来与无关的输入案例非常相似,但有点不同。让我们仔细看看。
当用户输入“a”时,该字符会被放入缓冲区。然后操作符»尝试将“a”提取到变量x中,而x的类型为double。由于“a”无法转换为double,因此操作符»无法执行提取。此时会发生两件事:“a”留在缓冲区中,std::cin进入“故障模式”。
一旦进入“故障模式”,后续输入提取请求都会自动失败。因此,在我们的程序中,输出提示仍然会打印,但任何进一步提取请求都会被忽略。这意味着进行输入操作时,会跳过输入提示符,并陷入无限循环。
幸运的是,我们可以检测提取是否失败:
1
2
3
4
5
6
|
if (std::cin.fail()) // 是否之前的提取失败
{
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
|
由于std::cin可以直接指示上一次提取操作的状态,因此更常见的写法是:
1
2
3
4
5
6
|
if (!std::cin) // 是否之前的提取失败
{
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
|
让我们将其集成到getDouble()函数中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
double getDouble()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
if (!std::cin) // 是否之前的提取失败
{
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
else // 或者之前提取成功
{
ignoreLine();
return x; // 这里返回提取的数据
}
}
}
|
由于无效输入而导致提取失败时,变量会被赋值为0。
在Unix系统上,输入文件尾(EOF)字符(键盘上输入Ctrl-D)会关闭输入流。这是std::cin.clear()无法修复的问题,此时std::cin永远不会离开故障模式,所有后续输入操作都会失败。这也会导致程序无限循环,直到被终止。
要更优雅地处理这种情况,可以显式检查是否EOF:
1
2
3
4
5
6
7
8
9
10
11
|
if (!std::cin) // 是否之前的提取失败
{
if (std::cin.eof()) // 是否输入流被关闭
{
exit(0); // 直接关闭程序
}
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
}
|
关键点
一旦提取失败,未来的输入提取请求(包括对ignore()的调用)将静默失败,直到调用clear()函数。因此,在检测到失败的提取后,调用clear()通常是您应该做的第一件事。
错误情况4:输入提取成功,但输入数值发生了溢出
考虑下面的简单示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
#include <cstdint>
#include <iostream>
int main()
{
std::int16_t x{}; // x 是 16 位, 存储范围 -32768 到 32767
std::cout << "Enter a number between -32768 and 32767: ";
std::cin >> x;
std::int16_t y{}; // y 是 16 位, 存储范围 -32768 到 32767
std::cout << "Enter another number between -32768 and 32767: ";
std::cin >> y;
std::cout << "The sum is: " << x + y << '\n';
return 0;
}
|
如果用户输入的数字太大(例如40000),会发生什么情况?
在上述情况下,std::cin会立即进入“故障模式”,但也会将范围内最接近的值分配给变量。因此,x会被赋值为32767。这时会跳过其他输入,将y保留为初始化值0。可以用与处理提取失败相同的方法来处理这种错误。
把它们放在一起
下面是我们的示例,其中增加了一些额外的错误检查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
#include <iostream>
#include <limits>
void ignoreLine()
{
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}
double getDouble()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter a decimal number: ";
double x{};
std::cin >> x;
// 检查是否提取失败
if (!std::cin) // 提取失败的情况
{
if (std::cin.eof()) // 是否输入流被关闭
{
exit(0); // 直接关闭程序
}
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
ignoreLine(); // 移除缓存中的错误数据
std::cout << "Oops, that input is invalid. Please try again.\n";
}
else
{
ignoreLine(); // 移除任何额外的输入
return x;
}
}
}
char getOperator()
{
while (true) // 无限循环,直到用户输入有效的数据
{
std::cout << "Enter one of the following: +, -, *, or /: ";
char operation{};
std::cin >> operation;
if (!std::cin) // 检查是否提取失败
{
if (std::cin.eof()) // 是否输入流被关闭
{
exit(0); // 直接关闭程序
}
// 这里来处理失败
std::cin.clear(); // 将std::cin调回 '正常' 模式
}
ignoreLine(); // 移除任何额外的输入
// 检查输入是否在范围内
switch (operation)
{
case '+':
case '-':
case '*':
case '/':
return operation; // 将输入返回
default: // 告诉用户输入错误
std::cout << "Oops, that input is invalid. Please try again.\n";
}
}
}
void printResult(double x, char operation, double y)
{
switch (operation)
{
case '+':
std::cout << x << " + " << y << " is " << x + y << '\n';
break;
case '-':
std::cout << x << " - " << y << " is " << x - y << '\n';
break;
case '*':
std::cout << x << " * " << y << " is " << x * y << '\n';
break;
case '/':
std::cout << x << " / " << y << " is " << x / y << '\n';
break;
default: // 即使getOperator()函数确保返回有效的输入,这里的检查可以让程序更加健壮
std::cout << "Something went wrong: printResult() got an invalid operator.\n";
}
}
int main()
{
double x{ getDouble() };
char operation{ getOperator() };
double y{ getDouble() };
printResult(x, operation, y);
return 0;
}
|
结论
编写程序时,请考虑用户会如何误用您的程序,特别是在文本输入方面。对于每个文本输入点,请考虑:
- 提取是否会失败?
- 用户是否可以输入比预期更多的输入?
- 用户是否可以输入无意义的输入?
- 用户输入是否会溢出?
可以使用if语句和布尔逻辑来测试输入是否符合预期且有意义。
以下代码将清除任何无关输入:
1
|
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
以下代码将测试并修复失败的提取或溢出(并删除无关输入):
1
2
3
4
5
6
7
8
9
10
11
|
if (!std::cin) // 是否之前提取失败,或者发生了溢出
{
if (std::cin.eof()) // 是否输入流关闭
{
exit(0); // 关闭程序
}
// 这里来处理故障
std::cin.clear(); // 将std::cin调回 '正常' 模式
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 移除缓存中的无关输入
}
|
我们可以测试是否存在未提取的输入(换行除外),如下所示:
1
2
3
4
5
6
7
|
// 是否有额外的输入
if (!std::cin.eof() && std::cin.peek() != '\n')
{
// 做我们想做的任何事情 -- 例如:
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); // 移除无关输入
continue; // 返回循环的顶部,让用户重新输入
}
|
最后,如果原始输入无效,则使用循环要求用户重新输入。
注
输入验证很重要,也很有用,但它也会使程序变得更复杂、更难理解。因此,在未来的课程中,通常不会进行任何类型的输入验证,除非它与正在讲解的内容相关。