Simplificando el empleo de std::cin

Última actualización: 16 de octubre de 2021

Introducción: Gestión del flujo de entrada estándar


El objeto global std::cin de la clase std::istream, proporcionado en la cabecera <iostream>, controla la entrada de datos desde un búfer de flujo asociado con el objeto stdin, propio del lenguaje C y declarado en <cstdio>. Sus siglas significan character input y es una de las las principales herramientas --junto a std::cout-- empleadas en cualquier curso introductorio del lenguaje. El objetivo de este artículo es el de diseñar una biblioteca que facilite el empleo de std::cin en el ámbito de la enseñanza. Asumiremos aquí el caso tradicional en el que los objetos std::cout y std::cin representan, respectivamente, un flujo de salida y de entrada para la terminal.

El uso de flujos estándar I/O en C++ es poco eficiente en algunos contextos, aun cuando proporcionen una herramienta extensible con seguridad de tipos. Su modo de empleo adolece también de cierta complejidad, si bien el nuevo estándar del lenguaje C++20 incluye una función std::format() que facilita enormemente el formato de texto y la inserción de datos [1]. Como alternativa al uso de flujos de salida estándar, el programador puede recurrir a la biblioteca {fmt}lib, que en buena medida hace innecesario el empleo de objetos std::ostream (incluyendo std::cout) y funciones como std::printf() y std::fprintf() [2].

En relación a la extracción de datos, existe una propuesta de estandarización para C++23 que facilita el parseo de texto y que está encaminada a remplazar el uso tradicional de flujos std::istream (incluyendo std::cin) y funciones como std::scanf [3, 4]. Nuestro estudio será, por supuesto, menos ambicioso, limitándose a diseñar una biblioteca que simplifique el uso de std::cin en los primeros pasos de aprendizaje del lenguaje. En efecto, como justificaremos a continuación, el manejo de este objeto no está exento de dificultades y un correcto tratamiento de los posibles errores de formato en los datos de entrada puede resultar inconveniente para los más principiantes.

A modo de ejemplo, consideremos el siguiente código sencillo que solicita al usuario la introducción por la terminal de un valor numérico de tipo double para, a continuación, requerir la entrada de un string:

using namespace std;     auto d = 0.0;    cout << "Insert a double: ";    (cin >> d).ignore(numeric_limits<streamsize>::max(), '\n');           auto s = string{};    cout << "Insert a string: ";    getline(cin, s);

Este código puede resultar excesivamente de bajo nivel para los estudiantes primerizos, obligando a su instructor a explicar detalles específicos del funcionamiento del flujo de datos que sería preferible omitir en las primeras clases.


Así, sería necesario advertir a los alumnos que la lectura del valor numérico d mediante el objeto cin y el operador de extracción operator>> mantendrá en el búfer de entrada intermedio cualquier cadena adicional indeseada de caracteres introducida tras el número, así como el salto de línea '\n' generado al pulsar RETURN. Como consecuencia, si el usuario introdujese por error la cadena 17.5gandalf\n, la variable d registraría correctamente 17.5, pero los restantes caracteres indeseados (gandalf\n) permanecerían remanentes en el búfer, interfiriendo en la operación de lectura posterior del string s, que se rellenaría automáticamente como gandalf sin dar mayor oportunidad al usuario. En efecto, la función getline() empleada en la lectura del string consumirá caracteres del búfer hasta encontrar su delimitador (por defecto, un salto de línea), que es también extraído pero descartado. Todo ello explica la llamada a cin.ignore() inmediatamente posterior a la lectura del valor numérico, con el fin de extraer y descartar el máximo número posible de caracteres indeseados, incluido el delimitador '\n'

De forma mas relevante aún, el código anterior no toma en consideración posibles estados de error del flujo de entrada, en particular los debidos a fallos de formato en los datos proporcionados por la terminal. Pensemos por ejemplo en la recepción de una letra cuando el programa espera el double, lo que activaría el flag ios_base::failbit en el flujo cin. Otros casos más improbables incluyen la corrupción irrecuperable del flujo, que activaría el flag ios_base::badbit, o la introducción de la combinación especial de caracteres EOF (Ctrl+D en Linux, Ctrl+Z-RETURN en MS Windows), que activaría el flag ios_base::eofbit. En el caso de la lectura del double, podríamos escribir:

   auto d = 0.0;    cout << "Insert a double: ";    while (!(cin >> d)) { // failbit o badbit activado       if (cin.bad() or cin.eof())           throw ios_base::failure{"irrecoverable stream error or EOF condition occurred"};       cout << "--->[invalid input] try again: ";       cin.clear();       cin.ignore(numeric_limits<streamsize>::max(), '\n');    }    cin.ignore(numeric_limits<streamsize>::max(), '\n');

De producirse un error de formato, la función cin.clear() resetea el estado del flujo de ios_base::failbit a ios_base::goodbit con el fin de poder seguir operando con el flujo cin.

Consideremos ahora el siguiente programa, que solicita al usuario la introducción de tantos nombres de contacto como se desee, registrando un número variable de números de teléfono para cada uno de ellos. En una primera aproximación, ignorando nuevamente posibles errores de formato en la lectura de los números de teléfono, podríamos proponer un código similar al siguiente:

auto contacts = map<string, vector<int>>{};  for (auto name = string{}; cout << "name: " and getline(cin, name); /* no-op */) {     for (auto phone = int{}; cout << "\tphone: " and cin >> phone; /* no-op */) {         cin.ignore(numeric_limits<streamsize>::max(), '\n');          contacts[name].push_back(phone);       }       cin.clear();  }  cin.clear();

Aquí, cada bucle for es implícitamente interrumpido mediante la introducción de la combinación especial EOF. Nuestra solución invoca la función cin.clear() cada vez que abandonamos un bucle con el fin de resetear el estado del flujo y seguir operando con la terminal. El que esta solución funcione depende, sin embargo, de la implementación: es posible que cin no sea reutilizable tras alcanzar EOF, o que para poder volver a usarlo sean necesarias acciones adicionales.

Mención aparte merece la posible introducción de condiciones adicionales sobre las variables --como el requerimiento de que los valores numéricos pertenezcan a un rango determinado o que los strings no estén vacíos--, lo que complicaría los códigos anteriores.


Funciones prompt() y prompt_init()


Ante las dificultades señaladas, resultaría muy conveniente diseñar una biblioteca sencilla en C++20 que permitiese la lectura segura de datos de tipos diversos (strings, ints, doubles, complexs, etcétera). Sus funciones gestionarían posibles fallos de formato, proporcionando al usuario nuevos intentos de lectura de introducir valores incorrectos. Asimismo, los detalles de más bajo nivel quedarían ocultos en la función, liberando al programador (en la medida de lo posible) de la tediosa gestión del flujo de entrada. Esta solución podría ser proporcionada a los estudiantes como herramienta auxiliar con el fin de facilitar su aprendizaje.

Implementaremos una primera función prompt() cuya sintaxis se basa vagamente en un método de idéntico nombre proporcionado en la biblioteca scnlib [4]. Su definición será incluida dentro del espacio de nombres terminal y sus argumentos serán, por este orden:
  1. Un mensaje que mostrar al usuario en la terminal.
  2. Una referencia al valor a leer (input), que habrá de ser definido fuera de la función.
  3. Una condición (predicado unario) que deba ser satisfecha por el input. Nuestro algoritmo solicitará repetidamente la entrada de un valor hasta que dicha condición se vea cumplida. El predicado adoptado por defecto, de nombre No_constraints, se evaluará a true para cualquier valor del tipo solicitado, por lo que en la práctica no impondrá restricciones sobre los inputs.
   namespace terminal {       namespace detail {          class Set_exceptions_policy {             std::ios_base::iostate old_policy_;          public:             explicit Set_exceptions_policy() : old_policy_{std::cin.exceptions()}             {                std::cin.exceptions(std::ios::badbit | std::ios::eofbit | std::ios::failbit);             }             ~Set_exceptions_policy() { std::cin.exceptions(old_policy_); }          };          template<typename T, typename Cond>          concept Readable = requires (T& value) {                                { std::cin >> value } -> std::same_as<std::istream&>;                             }                             and std::predicate<Cond, T>;       } // detail NAMESPACE              struct No_constraints {          template<typename T>          auto operator()(T const&) const noexcept { return true; }       };        template<typename T, typename Cond = No_constraints>          requires detail::Readable<T, Cond>       void prompt(std::string_view message, T& value, Cond cond = Cond{})        {          auto const _ = detail::Set_exceptions_policy{};          auto try_read = [](T& value, Cond cond) -> bool {             if constexpr (std::same_as<std::string, T>) {                std::getline(std::cin, value);                return std::invoke(cond, value);             }             else {                auto c = char{};                auto input = std::string{};                std::getline(std::cin, input);                auto strm = std::stringstream{input};                 return strm >> value and !(strm >> c) and std::invoke(cond, value);             }          };          std::cout << message;          while (!try_read(value, cond))             std::cout << "--->[invalid input] try again: ";       }       // siguientes funciones de namespace terminal...

Observemos en primer lugar que nuestra función prompt() optará por emitir una excepción de tipo ios_base::failure ante la posible activación de los flags ios_base::badbit, ios_base::eofbit y/o ios_base::failbit en el flujo cin. Dicha política de excepciones se establece a través del constructor de la clase detail::Set_exceptions_policy. El destructor de esta clase vuelve a activar la política original que tuviese el objeto cin antes de la llamada a la función prompt(), en un claro ejemplo de uso de la técnica RAII. Conviene remarcar en este punto que, tal y como discutiremos a continuación, los posibles errores de formato en los inputs se tratarán a través de la activación de failbit en un flujo diferente a cin, sin involucrar el lanzamiento de excepciones.

El predicado lambda try_read() es el encargado de extraer y determinar la validez del input. Su sentencia if constexpr --evaluada en tiempo de compilación-- permite operar con variables string de forma diferenciada frente a otros tipos de entrada. En el caso de valores de tipo distinto a string, la secuencia de caracteres introducida por el usuario es almacenada en un string auxiliar de nombre input. La clase stringstream dota entonces a input de una interfaz de más alto nivel, definiendo un flujo strm desde el que poder intentar extraer el dato value deseado mediante el operador operator>>. El carácter auxiliar c es empleado para impedir, por ejemplo, que una entrada como 17.5gandalf (valor numérico seguido de una cadena arbitraria de caracteres) pueda ser admitida como double 17.5. Cualquier error de formato que active el flag failbit en el flujo strm, la existencia de caracteres remanentes en dicho flujo o el incumplimiento de la condición a satisfacer por el valor causarán la evaluación de try_read() como false,  con la consiguiente emisión de un mensaje de error y una nueva solicitud de inserción de datos por parte de prompt().

El concepto detail::Readable<T,Cond> garantiza que el valor de la variable de tipo T pueda ser extraído de la terminal, así como que Cond sea un tipo de predicado unario para argumentos de tipo T. En caso contrario, se emitirá un mensaje de error en compilación de fácil comprensión. Notemos, finalmente, que la función prompt() proporciona una garantía básica antes excepciones para el dato a leer value.

El primer ejemplo de la introducción tomaría entonces la forma siguiente:

  auto d = 0.0;   terminal::prompt("Insert a double: ", d);   auto s = string{};   terminal::prompt("Insert a string: ", s);

Con el fin de aumentar la complejidad del caso considerado, podríamos exigir, por ejemplo, que el valor numérico introducido por el usuario fuese mayor que cero y que la cadena de caracteres no estuviese vacía:

  auto d = 0.0;   terminal::prompt("Insert a double > 0.0: ", d, [](auto a){ return a > 0.0; });   auto s = string{};   terminal::prompt("Insert a non-empty string: ", s, [](auto const& w){ return !w.empty(); });

La siguiente figura muestra una posible ejecución de este último código junto a la gestión de diversos errores de entrada (el primer mensaje de input inválido para el string es provocado por la introducción de una cadena de caracteres vacía):


Definiremos también una función auxiliar prompt_init() que inicialice ella misma por defecto una variable del tipo T a leer y proceda a su lectura por la terminal:

      // continuación de namespace terminal...       template<typename T, typename Cond = No_constraints>          requires std::default_initializable<T>               and detail::Readable<T, Cond>       [[nodiscard]] auto prompt_init(std::string_view message, Cond cond = Cond{}) -> T       {          auto value = T{};          prompt(message, value, std::move(cond));          return value;       }       // ...

Ello permitiría reconvertir el código anterior en la forma siguiente:

   auto const d = terminal::prompt_init<double>("Insert a double > 0.0: ",                                                 [](auto a){ return a > 0.0; });    auto const s = terminal::prompt_init<string>("Insert a non-empty string: ",                                                 [](auto const& w){ return !w.empty(); });

Observemos que, en este caso, podemos declarar las variables como constantes de deber permanecer éstas inmutables en nuestro código.

Por supuesto, nuestras funciones operarán también con clases que sobrecarguen adecuadamente el operador de extracción operator>>, como std::complex:

   auto const c = terminal::prompt_init<complex<double>>("Insert a complex number: ");


Función prompt_loop()


Finalmente, proporcionaremos una tercera función, de nombre prompt_loop, que permita leer variables de forma repetida en bucles for y while. Extenderemos la lista de argumentos de prompt() con una palabra clave para la interrupción de bucles, configurable por el programador. Por defecto, esta palabra centinela se encontrará definida como "<end>". La función prompt_loop() retornará el booleano true salvo cuando se introduzca por la terminal la palabra centinela, en cuyo caso retornará false. Ello nos permitirá interrumpir de forma natural bucles como los del segundo ejemplo de la introducción, sin necesidad de recurrir a soluciones de dudosa validez como la basada en la terminación EOF:

      // continuación de namespace terminal...       template<typename T, typename Cond = No_constraints>          requires detail::Readable<T, Cond>       [[nodiscard]] auto prompt_loop(std::string_view message,                                      T& value,                                      Cond cond = Cond{},                                      std::string_view sentinel = "<end>") -> bool        {          auto const _ = detail::Set_exceptions_policy{};                    auto try_read = [](T& value, std::string& input, Cond cond) -> bool {             if constexpr (std::same_as<std::string, T>) {                value = std::move(input);                return std::invoke(cond, value);             }             else {                auto c = char{};                auto strm = std::stringstream{input};                 return strm >> value and !(strm >> c) and std::invoke(cond, value);             }          };          std::cout << message;          auto input = std::string{};          while (std::getline(std::cin, input) and input != sentinel                  and !try_read(value, input, cond)) {             std::cout << "--->[invalid input] try again: ";          }          return input != sentinel;       }    } // terminal NAMESPACE

Consideremos nuevamente el código de ejemplo relativo a los nombres de contactos y números de teléfono discutido al inicio de este post. Una implementación más compleja de dicho programa, que requiriese que los nombres de contactos no estuviesen vacíos y que los números de teléfono constasen exactamente de ocho caracteres numéricos, tomaría la forma siguiente:

using namespace terminal;     auto contacts = map<string, vector<string>>{}; // nombre contacto -> números teléfono    auto non_empty = not_fn(&string::empty);    auto eight_digits = [rgx = regex{"\\d{8}"}](string const& p){ return regex_match(p, rgx); };    for (auto name = string{}; prompt_loop("name: ", name, non_empty); /* no-op */) {        for (auto phone = string{}; prompt_loop("\tphone: ", phone, eight_digits); /* no-op */)           contacts[name].push_back(phone);     }     for (auto const& [name, phone_numbers] : contacts) {        fmt::print("\n{:>10}: ", name);        for (string const& phone : phone_numbers)           fmt::print("{}, ", phone);     } 

La figura siguiente muestra una posible ejecución del programa, evidenciando el correcto tratamiento de errores en la introducción de los datos (el primer mensaje de input inválido se debe a que el primer nombre está vacío):



Fichero de cabecera terminal.hpp


A modo de resumen, se proporciona a continuación un fichero de cabecera terminal.hpp con las funciones prompt(), prompt_init() y prompt_loop() implementadas en este artículo. El código se encuentra distribuido bajo licencia MIT.

Como elemento de configuración adicional a los códigos analizados anteriormente, se ofrece al usuario la oportunidad de definir la macro TERMINAL_HPP_USE_FMTLIB de desear emplear la función fmt::print() de la biblioteca {fmt}lib al imprimir mensajes en la terminal, en sustitución del objeto std::cout.

   #ifndef TERMINAL_HPP_CPP_DGVERGEL_LIBRARY    #define TERMINAL_HPP_CPP_DGVERGEL_LIBRARY    /*       terminal.hpp       A teaching-oriented C++20 library to make coding with std::cin easier       ------------------------------------------------------------------------------       MIT License       Copyright (c) 2020 Daniel Gómez Vergel - dgvergel.blogspot.com              Permission is hereby granted, free of charge, to any person obtaining a copy       of this software and associated documentation files (the "Software"), to deal       in the Software without restriction, including without limitation the rights       to use, copy, modify, merge, publish, distribute, sublicense, and/or sell       copies of the Software, and to permit persons to whom the Software is       furnished to do so, subject to the following conditions:       The above copyright notice and this permission notice shall be included in all       copies or substantial portions of the Software.       THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR       IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,       FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE       AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER       LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,       OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE       SOFTWARE.    */    #include <concepts>    #include <iostream>     #include <functional>     #include <sstream>     #include <string>     #include <string_view>    #include <utility>    #ifdef TERMINAL_HPP_USE_FMTLIB        #include <fmt/core.h>    #endif     namespace terminal {       namespace detail {          void print(std::string_view message)          {             #ifdef TERMINAL_HPP_USE_FMTLIB                fmt::print("{}", message);             #else                std::cout << message;             #endif          }          class Set_exceptions_policy {             std::ios_base::iostate old_policy_;          public:             explicit Set_exceptions_policy() : old_policy_{std::cin.exceptions()}             {                std::cin.exceptions(std::ios::badbit | std::ios::eofbit | std::ios::failbit);             }             ~Set_exceptions_policy() { std::cin.exceptions(old_policy_); }          };          template<typename T, typename Cond>          concept Readable = requires (T& value) {                                { std::cin >> value } -> std::same_as<std::istream&>;                             }                             and std::predicate<Cond, T>;       } // detail NAMESPACE              struct No_constraints {          template<typename T>          auto operator()(T const&) const noexcept { return true; }       };        template<typename T, typename Cond = No_constraints>          requires detail::Readable<T, Cond>       void prompt(std::string_view message, T& value, Cond cond = Cond{})        {          auto const _ = detail::Set_exceptions_policy{};          auto try_read = [](T& value, Cond cond) -> bool {             if constexpr (std::same_as<std::string, T>) {                std::getline(std::cin, value);                return std::invoke(cond, value);             }             else {                auto c = char{};                auto input = std::string{};                std::getline(std::cin, input);                auto strm = std::stringstream{input};                 return strm >> value and !(strm >> c) and std::invoke(cond, value);             }          };          detail::print(message);          while (!try_read(value, cond))             detail::print("--->[invalid input] try again: ");       }       template<typename T, typename Cond = No_constraints>          requires std::default_initializable<T>               and detail::Readable<T, Cond>       [[nodiscard]] auto prompt_init(std::string_view message, Cond cond = Cond{}) -> T       {          auto value = T{};          prompt(message, value, std::move(cond));          return value;       }       template<typename T, typename Cond = No_constraints>          requires detail::Readable<T, Cond>       [[nodiscard]] auto prompt_loop(std::string_view message,                                      T& value,                                      Cond cond = Cond{},                                      std::string_view sentinel = "<end>") -> bool        {          auto const _ = detail::Set_exceptions_policy{};                    auto try_read = [](T& value, std::string& input, Cond cond) -> bool {             if constexpr (std::same_as<std::string, T>) {                value = std::move(input);                return std::invoke(cond, value);             }             else {                auto c = char{};                auto strm = std::stringstream{input};                 return strm >> value and !(strm >> c) and std::invoke(cond, value);             }          };          detail::print(message);          auto input = std::string{};          while (std::getline(std::cin, input) and input != sentinel                  and !try_read(value, input, cond)) {             detail::print("--->[invalid input] try again: ");          }          return input != sentinel;       }    } // terminal NAMESPACE    #endif


Referencias bibliográficas
  1. Cppreference - std::format - https://en.cppreference.com/w/cpp/utility/format/format
  2. {fmt} - A modern formatting library - https://fmt.dev/latest/index.html
  3. ISO C++ standards proposal - P1729 "Text Parsing" - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1729r1.html
  4. scnlib - https://github.com/eliaskosunen/scnlib

1 comentario:

  1. Hola. Muchas gracias por el aporte. Justo me va a servir ya que ahora estoy enseñando c++.

    ResponderEliminar