23.5 – Состояния потока и проверка корректности входных данных

Добавлено 9 октября 2021 в 21:35

Состояния потока

Класс ios_base содержит несколько флагов состояния, которые используются для сигнализации различных условий, которые могут возникнуть при использовании потоков:

ФлагНазначение
goodbitВсё в порядке
badbitПроизошла какая-то фатальная ошибка (например, программа попыталась прочитать после конца файла)
eofbitПоток достиг конца файла
failbitПроизошла нефатальная ошибка (например, пользователь ввел буквы, когда программа ожидала целое число)

Хотя эти флаги находятся в ios_base, но поскольку ios является производным от ios_base, а ios требует меньше ввода текста, чем ios_base, доступ к ним обычно осуществляется через него (например, как std::ios::failbit).

ios также предоставляет ряд функций-членов для удобного доступа к этим состояниям:

Функция-членНазначение
good()Возвращает true, если установлен goodbit (поток в норме)
bad()Возвращает true, если установлен badbit (произошла фатальная ошибка)
eof()Возвращает true, если установлен eofbit (поток находится в конце файла)
fail()Возвращает true, если установлен failbit (произошла нефатальная ошибка)
clear()Очищает все флаги и восстанавливает поток в состояние goodbit
clear(state)Очищает все флаги и устанавливает флаг состояния, переданный в параметре
rdstate()Возвращает текущие установленные флаги
setstate(state) Устанавливает флаг состояния, переданный в параметре

Чаще всего мы имеем дело failbit, который устанавливается, когда пользователь вводит недопустимые входные данные. Например, рассмотрим следующую программу:

std::cout << "Enter your age: ";
int age;
std::cin >> age;

Обратите внимание, что эта программа ожидает, что пользователь введет целое число. Однако если пользователь вводит нечисловые данные, такие как "Alex", cin не сможет извлечь что-либо для переменной возраста age, и будет установлен бит отказа failbit.

Если такая ошибка возникает, и для потока устанавливается значение, отличное от goodbit, дальнейшие операции с этим потоком будут проигнорированы. Это условие можно устранить, вызвав функцию clear().

Проверка корректности входных данных

Валидация (проверка корректности) входных данных – это процесс проверки того, соответствует ли пользовательский ввод некоторому набору критериев. Валидацию ввода обычно можно разделить на два типа: строковую и числовую.

При проверке строки мы принимаем весь пользовательский ввод как строку, а затем принимаем или отклоняем эту строку в зависимости от того, правильно ли она отформатирована. Например, если мы просим пользователя ввести номер телефона, мы можем проверить, что вводимые данные содержат десять цифр. В большинстве языков (особенно в скриптовых языках, таких как Perl и PHP) это делается с помощью регулярных выражений. Стандартная библиотека C++ также имеет библиотеку регулярных выражений. Регулярные выражения медленны по сравнению с проверкой строк вручную, и их следует использовать только в том случае, если производительность (время компиляции и время выполнения) не вызывает беспокойства, или ручная проверка слишком обременительна.

При числовой проверке мы обычно заботимся о том, чтобы число, вводимое пользователем, находилось в определенном диапазоне (например, от 0 до 20). Однако, в отличие от проверки строк, пользователь может вводить вещи, которые вообще не являются цифрами, и нам также необходимо обрабатывать эти случаи.

Чтобы помочь нам, C++ предоставляет ряд полезных функций, которые мы можем использовать для определения того, являются ли конкретные символы цифрами или буквами. В заголовке cctype находятся следующие функции:

ФункцияНазначение
std::isalnum(int)Возвращает ненулевое значение, если параметр представляет собой букву или цифру.
std::isalpha(int)Возвращает ненулевое значение, если параметр представляет собой букву.
std::iscntrl(int)Возвращает ненулевое значение, если параметр является управляющим символом.
std::isdigit(int)Возвращает ненулевое значение, если параметр является цифрой.
std::isgraph(int)Возвращает ненулевое значение, если параметр является печатным символом, который не является пробелом.
std::isprint(int)Возвращает ненулевое значение, если параметр является печатным символом (включая пробелы).
std::ispunct(int)Возвращает ненулевое значение, если параметр не является ни буквенно-цифровым, ни пробельным символом.
std::isspace(int)Возвращает ненулевое значение, если параметр – пробельный символ.
std::isxdigit(int)Возвращает ненулевое значение, если параметр является шестнадцатеричной цифрой (0-9, a-f, A-F).

Проверка строки

Примечание автора


С этого момента мы будем использовать функции, которые (пока) не описаны в этой серии статей. Если вы хорошо разбираетесь в C++, возможно, вы сможете понять, что делают эти функции, исходя из их названий и того, как они используются. Советуем посмотреть эти новые функции и типы в справочнике, чтобы лучше понять, что они делают, и для чего еще их можно использовать.

Давайте рассмотрим простой случай проверки строки, попросив пользователя ввести свое имя. Нашим критерием проверки будет то, что пользователь вводит только буквенные символы или пробелы. Если встретится что-то еще, ввод будет отклонен.

Когда дело доходит до входных данных переменной длины, лучший способ проверить строку (помимо использования библиотеки регулярных выражений) – это пройти по всем символам строки и убедиться, что они соответствует критериям проверки. Это именно то, что мы собираемся здесь сделать, или, лучше сказать, то, что std::all_of сделает для нас.

#include <algorithm> // std::all_of
#include <cctype>    // std::isalpha, std::isspace
#include <iostream>
#include <string>
#include <string_view>

bool isValidName(std::string_view name)
{
  return std::ranges::all_of(name, [](char ch) {
    return (std::isalpha(ch) || std::isspace(ch));
  });

  // До C++20, без диапазонов ranges
  // return std::all_of(name.begin(), name.end(), [](char ch) {
  //    return (std::isalpha(ch) || std::isspace(ch));
  // });
}

int main()
{
  std::string name{};

  do
  {
    std::cout << "Enter your name: ";
    std::getline(std::cin, name); // получаем всю строку, включая пробелы
  } while (!isValidName(name));

  std::cout << "Hello " << name << "!\n";
}

Обратите внимание, что этот код не идеален: пользователь мог сказать, что его имя "asf w jweo s di we ao", или какая-то другая тарабарщина, или, что еще хуже, просто несколько пробелов. Мы могли бы решить эту проблему, уточнив наши критерии проверки, чтобы принимать только строки, содержащие хотя бы один символ и не более одного пробела.

Теперь давайте рассмотрим другой пример, в котором мы попросим пользователя ввести свой номер телефона. В отличие от имени пользователя, которое имеет переменную длину и критерии проверки одинаковы для каждого символа, номер телефона имеет фиксированную длину, но критерии проверки различаются в зависимости от положения символа. Следовательно, для валидации нам необходим другой подход. В этом случае мы собираемся написать функцию, которая будет проверять данные, введенные пользователем, на соответствие заранее определенному шаблону, чтобы увидеть, соответствует ли они ему. Шаблон будет работать следующим образом:

  • # будет соответствовать любой цифре в данных, введенных пользователем;
  • @ будет соответствовать любому буквенному символу в пользовательском вводе;
  • _ будет соответствовать любому пробельному символу;
  • ? будет соответствовать чему угодно;
  • в противном случае символы в данных, введенных пользователем, и в шаблоне должны точно совпадать.

Итак, если мы спрашиваем функцию, соответствует ли строка шаблону "(###) ###-####", это означает, что мы ожидаем, что пользователь введет символ '(', три цифры, символ ')', пробел, три числа, дефис и еще четыре числа. Если что-либо из этого не совпадает, ввод будет отклонен.

Вот код:

#include <algorithm> // std::equal
#include <cctype>    // std::isdigit, std::isspace, std::isalpha
#include <iostream>
#include <map>
#include <string>
#include <string_view>

bool inputMatches(std::string_view input, std::string_view pattern)
{
  if (input.length() != pattern.length())
  {
    return false;
  }

  // Мы должны использовать указатель на функцию в стиле C,
  // потому что у std::isdigit и других есть перегрузки,
  // и иначе вызовы будут неоднозначными.
  static const std::map<char, int (*)(int)> validators{
    { '#', &std::isdigit },
    { '_', &std::isspace },
    { '@', &std::isalpha },
    { '?', [](int) { return 1; } }
  };

  // До C++20 используйте следующее
  // return std::equal(input.begin(), input.end(), pattern.begin(), [](char ch, char mask) -> bool {
  // ...

  return std::ranges::equal(input, pattern, [](char ch, char mask) -> bool {
    if (auto found{ validators.find(mask) }; found != validators.end())
    {
      // Текущий элемент шаблона был найден в validators. 
      // Вызов соответствующей функции.
      return (*found->second)(ch);
    }
    else
    {
      // Текущий элемент шаблона не найден в validators.
      // Символы должны точно совпадать.
      return (ch == mask);
    }
  });
}

int main()
{
  std::string phoneNumber{};

  do
  {
    std::cout << "Enter a phone number (###) ###-####: ";
    std::getline(std::cin, phoneNumber);
  } while (!inputMatches(phoneNumber, "(###) ###-####"));

  std::cout << "You entered: " << phoneNumber << '\n';
}

Используя эту функцию, мы можем заставить пользователя вводить данные, точно соответствующие нашему конкретному формату. Однако эта функция всё еще имеет несколько ограничений: Если #, @, _ и ? являются допустимыми символами в пользовательском вводе, эта функция не будет работать, потому что этим символам присвоено особое значение. Кроме того, в отличие от регулярных выражений, здесь нет шаблонного символа, означающего, что «можно ввести переменное количество символов». Таким образом, такой шаблон нельзя использовать для обеспечения того, чтобы пользователь вводил два слова, разделенных пробелом, поскольку он не может обработать тот факт, что слова имеют переменную длину. Для таких задач, как правило, более уместен нешаблонный подход.

Проверка чисел

При работе с числовым вводом очевидный способ обработки – использовать оператор извлечения для извлечения входных данных в переменную числового типа. Проверяя бит отказа (failbit), мы можем определить, ввел ли пользователь число или нет.

Давайте попробуем такой подход:

#include <iostream>
#include <limits>

int main()
{
    int age{};

    while (true)
    {
        std::cout << "Enter your age: ";
        std::cin >> age;

        if (std::cin.fail()) // извлечение не производилось
        {
            // сбрасываем биты состояния обратно в goodbit,
            // чтобы мы могли использовать ignore()
            std::cin.clear();
            // очищаем недопустимый ввод из потока
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); 
            // попробовать снова
            continue; 
        }

        if (age <= 0) // убедиться, что значение возраста положительное
            continue;

        break;
    }

    std::cout << "You entered: " << age << '\n';
}

Если пользователь вводит число, cin.fail() вернет false, и мы перейдем к инструкции break, выходя из цикла. Если пользователь вводит данные, начинающиеся с буквы, cin.fail() вернет true, и мы перейдем к условному выражению.

Однако есть еще один случай, который мы не проверили, и это когда пользователь вводит строку, которая начинается с цифр, но затем содержит буквы (например, "34abcd56"). В этом случае начальные числа (34) будут извлечены в переменную age, а остаток строки ("abcd56") останется во входном потоке, и бит отказа НЕ будет установлен. Это вызывает две потенциальные проблемы:

  1. если вы хотите, чтобы это был допустимый ввод, теперь в вашем потоке есть мусор;
  2. если вы не хотите, чтобы это был допустимый ввод, он не отклоняется (и в вашем потоке есть мусор);

Решим первую проблему. Это просто:

#include <iostream>
#include <limits>

int main()
{
    int age{};

    while (true)
    {
        std::cout << "Enter your age: ";
        std::cin >> age;

        if (std::cin.fail()) // извлечение не производилось
        {
            // сбрасываем биты состояния обратно в goodbit,
            // чтобы мы могли использовать ignore()
            std::cin.clear(); 
            // очищаем недопустимый ввод из потока
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); 
            // попробовать снова
            continue; 
        }

        // очищаем любой дополнительный ввод из потока
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); 

        if (age <= 0) // убедиться, что значение возраста положительное
            continue;

      break;
    }

    std::cout << "You entered: " << age << '\n';
}

Если вы не хотите, чтобы такие данные были допустимыми, нам придется проделать небольшую дополнительную работу. К счастью, предыдущее решение помогает нам в этом. Мы можем использовать функцию gcount(), чтобы определить, сколько символов было проигнорировано. Если наш ввод был допустимым, gcount() должна вернуть 1 (отброшенный символ новой строки). Если она возвращает больше 1, пользователь ввел что-то, что не было извлечено правильно, и мы должны попросить его ввести новые данные. Ниже показан пример этого:

#include <iostream>
#include <limits>

int main()
{
    int age{};

    while (true)
    {
        std::cout << "Enter your age: ";
        std::cin >> age;

        if (std::cin.fail()) // извлечение не производилось
        {
            // сбрасываем биты состояния обратно в goodbit,
            // чтобы мы могли использовать ignore()
            std::cin.clear();
            // очищаем недопустимый ввод из потока
            std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); 
            // попробовать снова
            continue; 
        }
 
        // очищаем любой дополнительный ввод из потока
        std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); 
        if (std::cin.gcount() > 1) // если мы удалили более одного дополнительного символа
        {
            continue; // будем считать этот ввод недопустимым
        }

        if (age <= 0) // убедиться, что значение возраста положительное
        {
            continue;
        }

        break;
    }

    std::cout << "You entered: " << age << '\n';
}

Проверка чисел в виде строки

В приведенном выше примере потребовалось довольно много работы, чтобы просто получить простое значение! Другой способ обработки числовых входных данных – прочитать их как строку, а затем попытаться преобразовать в переменную числового типа. Следующая программа использует этот метод:

#include <charconv> // std::from_chars
#include <iostream>
#include <optional>
#include <string>
#include <string_view>

std::optional<int> extractAge(std::string_view age)
{
  int result{};
  auto end{ age.data() + age.length() };

  // Пытаемся извлечь int из строки age
  if (std::from_chars(age.data(), end, result).ptr != end)
  {
    return {};
  }

  if (result <= 0) // убедиться, что значение возраста положительное
  {
    return {};
  }

  return result;
}

int main()
{
  int age{};

  while (true)
  {
    std::cout << "Enter your age: ";
    std::string strAge{};
    std::cin >> strAge;

    if (auto extracted{ extractAge(strAge) })
    {
      age = *extracted;
      break;
    }
  }

  std::cout << "You entered: " << age << '\n';
}

Будет ли этот подход более или менее трудоемким, чем извлечение чисел напрямую, зависит от ваших параметров проверки и ограничений.

Как видите, проверка ввода в C++ – это большая работа. К счастью, многие такие задачи (например, выполнение проверки чисел в виде строки) можно легко превратить в функции, которые можно повторно использовать в самых разных ситуациях.

Теги

C++ / CppiostreamLearnCppstd::cinstd::iosSTL / Standard Template Library / Стандартная библиотека шаблоновВалидацияВвод/выводДля начинающихОбучениеПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.