9.5 — std::cin 和处理无效输入

大多数具有某种用户界面的程序都需要处理用户输入。在您一直在编写的程序中,您一直使用 `std::cin` 来要求用户输入文本。由于文本输入非常自由(用户可以输入任何内容),用户很容易输入意外的输入。

在编写程序时,您应该始终考虑用户将如何(无意或有心)滥用您的程序。一个编写良好的程序将预测用户将如何滥用它,并优雅地处理这些情况或(如果可能)从一开始就阻止它们发生。一个能够很好地处理错误情况的程序被称为**健壮**的。

在本课程中,我们将专门探讨用户通过 `std::cin` 输入无效文本输入的方式,并向您展示处理这些情况的不同方法。

在我们讨论 `std::cin` 和 `operator>>` 如何失败之前,让我们回顾一下它们的工作原理。我们在第 1.5 课 -- iostream 简介:cout、cin 和 endl 中讨论了这些内容。

以下是 `operator>>` 用于输入的简化视图

  1. 首先,前导空格(缓冲区开头的空格、制表符和换行符)将从输入缓冲区中丢弃。这将丢弃先前输入行中剩余的任何未提取的换行符。
  2. 如果输入缓冲区现在为空,`operator>>` 将等待用户输入更多数据。前导空格再次被丢弃。
  3. `operator>>` 然后提取尽可能多的连续字符,直到遇到换行符(表示输入行的末尾)或对要提取到的变量无效的字符。

提取结果如下

  • 如果在上面的第 3 步中提取了任何字符,则提取成功。提取的字符将被转换为一个值,然后分配给变量。
  • 如果在上面的第 3 步中没有提取任何字符,则提取失败。要提取到的对象被赋值为 `0`(从 C++11 开始),并且任何未来的提取将立即失败(直到 `std::cin` 被清除)。

验证输入

检查用户输入是否符合程序预期要求的过程称为**输入验证**。

输入验证有三种基本方法

内联(用户输入时)

  1. 从一开始就阻止用户输入无效输入。

输入后(用户输入后)

  1. 让用户将他们想要的任何内容输入到字符串中,然后验证字符串是否正确,如果是,则将字符串转换为最终的变量格式。
  2. 让用户输入他们想要的任何内容,让 `std::cin` 和 `operator>>` 尝试提取,并处理错误情况。

一些图形用户界面和高级文本界面将允许您在用户输入时(逐个字符)验证输入。一般来说,程序员提供一个验证函数,该函数接受用户目前已输入的输入,如果输入有效则返回 `true`,否则返回 `false`。每次用户按下按键时都会调用此函数。如果验证函数返回 `true`,则接受用户刚刚按下的按键。如果验证函数返回 `false`,则丢弃用户刚刚输入的字符(并且不在屏幕上显示)。使用此方法,您可以确保用户输入的任何输入都保证有效,因为任何无效的击键都会立即被发现并丢弃。不幸的是,`std::cin` 不支持这种验证方式。

由于字符串对可以输入的字符没有任何限制,因此提取保证成功(但请记住 `std::cin` 在第一个非前导空格字符处停止提取)。一旦输入字符串,程序就可以解析字符串以查看其是否有效。然而,解析字符串并将字符串输入转换为其他类型(例如数字)可能具有挑战性,因此这只在极少数情况下进行。

最常见的情况是,我们让 `std::cin` 和提取运算符完成繁重的工作。在这种方法下,我们让用户输入他们想要的任何内容,让 `std::cin` 和 `operator>>` 尝试提取它,并在失败时处理后果。这是最简单的方法,也是我们将在下面更多讨论的方法。

示例程序

考虑以下没有错误处理的计算器程序

#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)
{
    std::cout << x << ' ' << operation << ' ' << y << " is ";

    switch (operation)
    {
    case '+':
        std::cout << x + y << '\n';
        return;
    case '-':
        std::cout << x - y << '\n';
        return;
    case '*':
        std::cout << x * y << '\n';
        return;
    case '/':
        std::cout << x / y << '\n';
        return;
    }
}
 
int main()
{
    double x{ getDouble() };
    char operation{ getOperator() };
    double y{ getDouble() };

    printResult(x, operation, y);
 
    return 0;
}

这个简单的程序要求用户输入两个数字和一个数学运算符。

Enter a decimal number: 5
Enter one of the following: +, -, *, or /: *
Enter a decimal number: 7
5 * 7 is 35

现在,考虑无效的用户输入可能会破坏此程序的哪些地方。

首先,我们要求用户输入一些数字。如果他们输入数字以外的内容(例如 'q')怎么办?在这种情况下,提取将失败。

其次,我们要求用户输入四个可能符号中的一个。如果他们输入我们预期的符号之外的字符怎么办?我们将能够提取输入,但我们目前不处理之后发生的情况。

第三,如果我们要求用户输入一个符号,而他们输入一个字符串,如 `"*q hello"`,怎么办?尽管我们可以提取我们需要的 `*` 字符,但缓冲区中还有额外的输入,可能会在以后引起问题。

无效文本输入的类型

我们通常可以将输入文本错误分为四种类型

  • 输入提取成功,但输入对程序没有意义(例如,输入 'k' 作为您的数学运算符)。
  • 输入提取成功,但用户输入了额外内容(例如,输入 `*q hello` 作为您的数学运算符)。
  • 输入提取失败(例如,尝试将 'q' 输入到数字输入中)。
  • 输入提取成功,但用户使数字值溢出。

因此,为了使我们的程序健壮,每当我们要求用户输入时,理想情况下我们应该确定上述每种情况是否可能发生,如果可能,则编写代码来处理这些情况。

让我们深入探讨这些情况,以及如何使用 `std::cin` 处理它们。

错误情况 1:提取成功但输入无意义

这是最简单的情况。考虑上述程序的以下执行

Enter a decimal number: 5
Enter one of the following: +, -, *, or /: k
Enter a decimal number: 7

在这种情况下,我们要求用户输入四个符号中的一个,但他们输入了 'k'。'k' 是一个有效字符,因此 `std::cin` 愉快地将其提取到变量 `op` 中,并将其返回到 `main`。但我们的程序没有预料到这种情况会发生,因此它没有正确处理这种情况。结果,它输出

5 k 7 is

这里的解决方案很简单:进行输入验证。这通常包括 3 个步骤

  1. 检查用户的输入是否符合您的预期。
  2. 如果是,将值返回给调用者。
  3. 如果不是,告诉用户出了问题,让他们再试一次。

以下是更新后的 `getOperator()` 函数,它进行了输入验证。

char getOperator()
{
    while (true) // Loop until user enters a valid input
    {
        std::cout << "Enter one of the following: +, -, *, or /: ";
        char operation{};
        std::cin >> operation;

        // Check whether the user entered meaningful input
        switch (operation)
        {
        case '+':
        case '-':
        case '*':
        case '/':
            return operation; // return it to the caller
        default: // otherwise tell the user what went wrong
            std::cout << "Oops, that input is invalid.  Please try again.\n";
        }
    } // and try again
}

如您所见,我们正在使用 `while` 循环不断循环,直到用户提供有效输入。如果他们不这样做,我们要求他们再试一次,直到他们给我们有效输入,关闭程序,或者破坏他们的计算机。

错误情况 2:提取成功但存在多余输入

考虑上述程序的以下执行

Enter a decimal number: 5*7

你认为接下来会发生什么?

Enter a decimal number: 5*7
Enter one of the following: +, -, *, or /: Enter a decimal number: 5 * 7 is 35

程序打印了正确的答案,但格式完全混乱了。让我们仔细看看原因。

当用户输入 `5*7` 时,该输入进入缓冲区。然后 `operator>>` 将 5 提取到变量 x,在缓冲区中留下 `*7\n`。接下来,程序打印“请输入以下之一:+、-、* 或 /:”。然而,当调用提取运算符时,它看到 `*7\n` 在缓冲区中等待提取,因此它使用该内容而不是要求用户输入更多内容。因此,它提取了 '*' 字符,在缓冲区中留下 `7\n`。

在要求用户输入另一个十进制数字后,缓冲区中的 `7` 在未询问用户的情况下被提取。由于用户从未有机会输入额外数据并按下 Enter 键(导致换行符),因此所有输出提示都在同一行上运行。

尽管上述程序有效,但执行过程很混乱。如果简单地忽略任何输入的多余字符会更好。幸运的是,忽略字符很简单

std::cin.ignore(100, '\n');  // clear up to 100 characters out of the buffer, or until a '\n' character is removed

此调用最多可删除 100 个字符,但如果用户输入超过 100 个字符,我们将再次得到混乱的输出。要忽略所有字符直到下一个“\n”,我们可以将 `std::numeric_limits::max()` 传递给 `std::cin.ignore()`。`std::numeric_limits::max()` 返回可以存储在 `std::streamsize` 类型变量中的最大值。将此值传递给 `std::cin.ignore()` 会使其禁用计数检查。

要忽略所有内容,直到并包括下一个“\n”字符,我们调用

std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');

因为这行代码很长,所以把它封装在一个函数中会很方便,可以代替 `std::cin.ignore()` 调用。

#include <limits> // for std::numeric_limits

void ignoreLine()
{
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}

由于用户输入的最后一个字符通常是“\n”,我们可以告诉 `std::cin` 忽略缓冲字符,直到它找到一个换行符(该换行符也会被删除)。

让我们更新我们的 `getDouble()` 函数,以忽略任何多余的输入

double getDouble()
{
    std::cout << "Enter a decimal number: ";
    double x{};
    std::cin >> x;

    ignoreLine();
    return x;
}

现在我们的程序将按预期工作,即使我们为第一个输入输入 `5*7` —— 5 将被提取,其余字符将从输入缓冲区中删除。由于输入缓冲区现在为空,因此下次执行提取操作时,将正确要求用户输入!

提示

在某些情况下,最好将多余的输入视为失败情况(而不是简单地忽略它)。然后我们可以要求用户重新输入他们的输入。

以下是 `getDouble()` 的变体,如果存在任何多余的输入,它会要求用户重新输入他们的输入

// returns true if std::cin has unextracted input on the current line, false otherwise
bool hasUnextractedInput()
{
    return !std::cin.eof() && std::cin.peek() != '\n';
}

double getDouble()
{
    while (true) // Loop until user enters a valid input
    {
        std::cout << "Enter a decimal number: ";
        double x{};
        std::cin >> x;

        // NOTE: YOU SHOULD CHECK FOR A FAILED EXTRACTION HERE (see section below)

        // If there is extraneous input, treat as failure case
        if (hasUnextractedInput())
        {
            ignoreLine(); // remove extraneous input
            continue;
        }
    
        return x;
    }
}

上面的代码片段使用了我们以前没有见过的两个函数

  • `std::cin.eof()` 函数如果上次输入操作(在本例中是提取到 `x`)到达输入流的末尾,则返回 `true`。
  • `std::cin.peek()` 函数允许我们查看输入流中的下一个字符,而无需提取它。

此函数的工作原理如下。将用户输入提取到 `x` 后,`std::cin` 中可能存在或不存在额外的(未提取的)字符。

首先,我们调用 `std::cin.eof()` 查看提取到 `x` 是否到达了输入流的末尾。如果是,那么我们知道所有字符都已提取,这是一个成功的情况。

否则,`std::cin` 中肯定还有其他字符等待提取。在这种情况下,我们调用 `std::cin.peek()` 来查看下一个等待提取的字符,而无需实际提取它。如果下一个字符是 `'\n'`,这意味着我们已经将此输入行中的所有字符都提取到了 `x` 中。这同样也是一个成功的情况。

然而,如果下一个字符不是 `'\n'`,那么用户一定输入了多余的输入,而这些输入没有被提取到 `x` 中。这就是我们的失败情况。我们清除所有这些多余的输入,然后 `continue` 回到循环的顶部,再试一次。

如果您在理解 `hasUnextractedInput()` 中的布尔表达式如何求值时遇到困难,这并不奇怪——带有否定的布尔表达式可能难以理解。在这种情况下,使用德摩根定律可以有所帮助。一个等价的语句是 `return !(std::cin.eof() || std::cin.peek() == '\n');`。这更清楚地表明我们正在测试 EOF 或换行符。如果其中任何一个为真,那么我们已经提取了所有输入。然后我们应用 `operator!` 来告诉我们是否没有提取所有输入,这意味着仍然存在未提取的输入。

错误情况 3:提取失败

当无法将任何输入提取到指定变量时,提取失败。

现在考虑我们更新后的计算器程序的以下执行

Enter a decimal number: a

程序没有按预期执行,这不应该让您感到惊讶,但它的失败方式很有趣

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”时,该字符被放置在缓冲区中。然后 `operator>>` 尝试将“a”提取到 `double` 类型的变量 x 中。由于“a”无法转换为 `double`,`operator>>` 无法进行提取。此时发生两件事:“a”留在缓冲区中,并且 `std::cin` 进入“故障模式”。

一旦进入“故障模式”,未来的输入提取请求将静默失败。因此在我们的计算器程序中,输出提示仍然打印,但任何进一步的提取请求都将被忽略。这意味着我们不会等待输入操作,而是跳过输入提示,我们陷入一个无限循环,因为无法到达有效情况之一。

为了让 `std::cin` 再次正常工作,我们通常需要做三件事

  • 检测到先前的提取失败。
  • 将 `std::cin` 恢复到正常操作模式。
  • 删除导致失败的输入(这样下一个提取请求就不会以相同的方式失败)。

以下是它看起来的样子

if (std::cin.fail()) // If the previous extraction failed
{
    // Let's handle the failure
    std::cin.clear(); // Put us back in 'normal' operation mode
    ignoreLine();     // And remove the bad input
}

由于 `std::cin` 具有一个布尔转换,指示上次输入是否成功,因此将上述代码写成以下形式更符合习惯

if (!std::cin) // If the previous extraction failed
{
    // Let's handle the failure
    std::cin.clear(); // Put us back in 'normal' operation mode
    ignoreLine();     // And remove the bad input
}

关键见解

一旦提取失败,未来的输入提取请求(包括对 `ignore()` 的调用)将静默失败,直到调用 `clear()` 函数。因此,在检测到提取失败后,调用 `clear()` 通常是您应该做的第一件事。

让我们把它整合到我们的 `getDouble()` 函数中

double getDouble()
{
    while (true) // Loop until user enters a valid input
    {
        std::cout << "Enter a decimal number: ";
        double x{};
        std::cin >> x;

        if (!std::cin) // If the previous extraction failed
        {
            // Let's handle the failure
            std::cin.clear(); // Put us back in 'normal' operation mode
            ignoreLine();     // And remove the bad input
            continue;
        }

        // Our extraction succeeded
        ignoreLine(); // Ignore any additional input on this line
        return x;     // Return the value we extracted
    }
}

对于基本类型,由于无效输入导致的提取失败将导致变量被赋值为 `0`(或 `0` 在变量类型中转换成的任何值)。

即使提取没有失败,也可以调用 `clear()` —— 它不会做任何事情。在无论成功还是失败我们都将调用 `ignoreLine()` 的情况下,我们基本上可以将两种情况结合起来

double getDouble()
{
    while (true) // Loop until user enters a valid input
    {
        std::cout << "Enter a decimal number: ";
        double x{};
        std::cin >> x;

        bool success { std::cin }; // Remember whether we had a successful extraction
        std::cin.clear();          // Put us back in 'normal' operation mode (in case we failed)
        ignoreLine();              // Ignore any additional input on this line (regardless)

        if (success)               // If we actually extracted a value
            return x;              // Return it (otherwise, we go back to top of loop)
    }
}

检查 EOF

我们还需要解决另一种情况。

文件结束(EOF)是一种特殊的错误状态,表示“没有更多数据可用”。这通常在输入操作因没有数据可用而失败**之后**生成。例如,如果您正在读取磁盘文件的内容,然后尝试在您已经到达文件末尾后读取更多数据,则会生成 EOF 以告知您没有更多数据可用。在文件输入的情况下,这不是问题——我们可以直接关闭文件并继续。

现在考虑 `std::cin`。如果我们在 `std::cin` 中尝试提取输入而没有输入,它设计上不会生成 EOF —— 它只会等待用户输入更多内容。但是,`std::cin` 在某些情况下会生成 EOF —— 最常见的是当用户为其操作系统输入特殊按键组合时。Unix(通过 ctrl-D)和 Windows(通过 ctrl-Z + ENTER)都支持从键盘输入“EOF 字符”。

关键见解

在 C++ 中,EOF 是一种错误状态,而不是一个字符。不同的操作系统有特殊的字符组合被视为“用户输入的 EOF 请求”。这些有时被称为“EOF 字符”。

当将数据提取到 `std::cin` 并且用户输入 EOF 字符时,行为是操作系统特定的。通常会发生以下情况

  • 如果 EOF 不是输入的第一个字符:EOF 之前的所有输入都将被刷新,并且 EOF 字符被忽略。在 Windows 上,EOF 之后输入的任何字符(除了换行符)都将被忽略。
  • 如果 EOF 是输入的第一个字符:EOF 错误将被设置。输入流可能会(或可能不会)断开连接。

虽然 `std::cin.clear()` 会清除 EOF 错误,但如果输入流已断开连接,则下一个输入请求将生成另一个 EOF 错误。当我们的输入在一个 `while(true)` 循环中时,这会产生问题,因为我们将陷入 EOF 错误的无限循环。

由于键盘输入的 `EOF` 字符的目的是终止输入流,因此最好的做法是检测 EOF(通过 `std::cin.eof()`),然后终止程序。

因为清除失败的输入流是我们可能经常检查的事情,所以这是一个可重用函数的好候选者

#include <limits> // for std::numeric_limits

void ignoreLine()
{
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}

// returns true if extraction failed, false otherwise
bool clearFailedExtraction()
{
    // Check for failed extraction
    if (!std::cin) // If the previous extraction failed
    {
        if (std::cin.eof()) // If the user entered an EOF
        {
            std::exit(0); // Shut down the program now
        }

        // Let's handle the failure
        std::cin.clear(); // Put us back in 'normal' operation mode
        ignoreLine();     // And remove the bad input

        return true;
    }
    
    return false;
}

错误情况 4:提取成功但用户溢出数字值

考虑以下简单示例

#include <cstdint>
#include <iostream>

int main()
{
    std::int16_t x{}; // x is 16 bits, holds from -32768 to 32767
    std::cout << "Enter a number between -32768 and 32767: ";
    std::cin >> x;

    std::int16_t y{}; // y is 16 bits, holds from -32768 to 32767
    std::cout << "Enter another number between -32768 and 32767: ";
    std::cin >> y;

    std::cout << "The sum is: " << x + y << '\n';
    return 0;
}

如果用户输入的数字过大(例如 40000)会发生什么?

Enter a number between -32768 and 32767: 40000
Enter another number between -32768 and 32767: The sum is: 32767

在上述情况下,`std::cin` 立即进入“故障模式”,但也会将最接近范围内的值赋给变量。当输入的值大于该类型的最大可能值时,最接近范围内的值就是该类型的最大可能值。因此,`x` 的赋值值为 `32767`。额外的输入被跳过,导致 `y` 的初始值为 `0`。我们可以像处理失败的提取一样处理这种错误。

融会贯通

这是我们的示例计算器,更新了一些额外的错误检查

#include <cstdlib> // for std::exit
#include <iostream>
#include <limits> // for std::numeric_limits

void ignoreLine()
{
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}

// returns true if extraction failed, false otherwise
bool clearFailedExtraction()
{
    // Check for failed extraction
    if (!std::cin) // If the previous extraction failed
    {
        if (std::cin.eof()) // If the stream was closed
        {
            std::exit(0); // Shut down the program now
        }

        // Let's handle the failure
        std::cin.clear(); // Put us back in 'normal' operation mode
        ignoreLine();     // And remove the bad input

        return true;
    }
    
    return false;
}

double getDouble()
{
    while (true) // Loop until user enters a valid input
    {
        std::cout << "Enter a decimal number: ";
        double x{};
        std::cin >> x;

        if (clearFailedExtraction())
        {
            std::cout << "Oops, that input is invalid.  Please try again.\n";
            continue;
        }

        ignoreLine(); // Remove any extraneous input
        return x;     // Return the value we extracted
    }
}

char getOperator()
{
    while (true) // Loop until user enters a valid input
    {
        std::cout << "Enter one of the following: +, -, *, or /: ";
        char operation{};
        std::cin >> operation;

        if (!clearFailedExtraction()) // we'll handle error messaging if extraction failed below
             ignoreLine(); // remove any extraneous input (only if extraction succeded)

        // Check whether the user entered meaningful input
        switch (operation)
        {
        case '+':
        case '-':
        case '*':
        case '/':
            return operation; // Return the entered char to the caller
        default: // Otherwise tell the user what went wrong
            std::cout << "Oops, that input is invalid.  Please try again.\n";
        }
    }
}
 
void printResult(double x, char operation, double y)
{
    std::cout << x << ' ' << operation << ' ' << y << " is ";

    switch (operation)
    {
    case '+':
        std::cout << x + y << '\n';
        return;
    case '-':
        std::cout << x - y << '\n';
        return;
    case '*':
        std::cout << x * y << '\n';
        return;
    case '/':
        if (y == 0.0)
            break;

        std::cout << x / y << '\n';
        return;
    }

    std::cout << "???";  // Being robust means handling unexpected parameters as well, even though getOperator() guarantees operation is valid in this particular program
}
 
int main()
{
    double x{ getDouble() };
    char operation{ getOperator() };
    double y{ getDouble() };

    // Handle division by 0
    while (operation == '/' && y == 0.0) 
    {
        std::cout << "The denominator cannot be zero.  Try again.\n";
        y = getDouble();
    }
 
    printResult(x, operation, y);
 
    return 0;
}

总结

在编写程序时,请考虑用户将如何滥用您的程序,尤其是在文本输入方面。对于每个文本输入点,请考虑

  • 提取会失败吗?
  • 用户会输入比预期更多的输入吗?
  • 用户会输入无意义的输入吗?
  • 用户会使输入溢出吗?

您可以使用 `if` 语句和布尔逻辑来测试输入是否符合预期且有意义。

以下代码将清除任何多余的输入

#include <limits> // for std::numeric_limits

void ignoreLine()
{
    std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
}

以下代码将测试并修复失败的提取或溢出(并删除多余的输入)

// returns true if extraction failed, false otherwise
bool clearFailedExtraction()
{
    // Check for failed extraction
    if (!std::cin) // If the previous extraction failed
    {
        if (std::cin.eof()) // If the stream was closed
        {
            std::exit(0); // Shut down the program now
        }

        // Let's handle the failure
        std::cin.clear(); // Put us back in 'normal' operation mode
        ignoreLine();     // And remove the bad input

        return true;
    }
    
    return false;
}

我们可以通过以下方式检测是否存在未提取的输入(除了换行符)

// returns true if std::cin has unextracted input on the current line, false otherwise
bool hasUnextractedInput()
{
    return !std::cin.eof() && std::cin.peek() != '\n';
}

最后,如果原始输入无效,请使用循环要求用户重新输入。

作者注

输入验证很重要且有用,但它也往往使示例更复杂,更难理解。因此,在未来的课程中,我们通常不会进行任何类型的输入验证,除非它与我们试图教授的内容相关。

guest
您的电子邮箱地址将不会被显示
发现错误?请在上方留言!
与勘误相关的评论在处理后将被删除,以帮助减少混乱。感谢您帮助使网站对每个人都更好!
来自 https://gravatar.com/ 的头像与您提供的电子邮箱地址相关联。
有回复时通知我:  
550 条评论
最新
最早 最多投票
内联反馈
查看所有评论