Una historia de conversión: mejorando from_chars y to_chars en C++17

El Domo del Panteón, Roma

NOTA: Puedes obtener el código fuente y ejemplos en Github en https://github.com/ljestrada/charconvutils y ver mi charla CPPCON2019 Lightning Challenge en YouTube aquí, y la versión en inglés de esta publicación aquí.

No, no se trata de una experiencia religiosa, epifanía, cambio de fe o de un encuentro con mi creador. Más bien, es sobre C++.

C++17 proporciona dos funciones de conversión de bajo nivel,  std::from_chars y std::to_chars, en el archivo de encabezado <charconv>, pero tienen un modelo de uso que puede mejorarse fácilmente usando el poder de las plantillas.

from_chars y to_chars son atractivas porque tienen un número de garantías agradables: independientes de la configuración regional (locale), no asignan memoria y no lanzan excepciones. Compara std::to_chars con std::to_string, que es dependiente de la configuración regional y puede lanzar bad_alloc. Dicho esto, las funciones son en realidad un conjunto de funciones sobrecargadas para tipos enteros, char, y tipos de punto flotante (float, double y long double). Observa que bool no se soporta, ni tampoco los otros tipos de caracteres.

Nicolai Josuttis tiene una cobertura excelente y dedica un capítulo completo a estas funciones en su libro C++17 – La guía completa. Ahí podrás encontrar ejemplos de from_chars y to_chars usando vínculos estructurados e if con un inicializador, otras dos nuevas adiciones a C++17. Además, puedes referirte a la documentación en es.cppreference.com para más ejemplos.

Las signaturas de las funciones para conversión de caracteres from_chars son las siguientes:

std::from_chars_result from_chars(
  const char* first, const char* last, /*véase descripción*/& value,
  int base = 10);

std::from_chars_result from_chars(
  const char* first, const char* last, float& value, 
  std::chars_format fmt = std::chars_format::general);

std::from_chars_result from_chars(
  const char* first, const char* last, double& value,
  std::chars_format fmt = std::chars_format::general);

std::from_chars_result from_chars(
  const char* first, const char* last, long double& value,
  std::chars_format fmt = std::chars_format::general);

struct from_chars_result {
    const char* ptr;
    std::errc ec;
};  

La primer signatura es para tipos enteros. El comentario /*véase descripción*/ se refiere a los tipos enteros que se soportan, así como a char (nuevamente, pero no a bool o a cualquier otro de los tipos de caracteres). El resto de las signaturas son para los tipos de punto flotante. La estructura from_chars_result se utiliza para almacenar el resultado. Aunque las signaturas de las funciones no están marcadas con el especificador noexcept, la documentación declara que no lanzan excepciones–los errores se comunican mediante el miembro ec en la estructura from_chars_result. Como una anécdota, la implementación de Microsoft “refuerza” las funciones marcándolas con el especificador noexcept.

De manera similar, las signaturas de las funciones para las funciones de conversión de caracteres to_chars son las siguientes:

std::to_chars_result to_chars(
  char* first, char* last, /*véase descripción*/ value, int base = 10);

std::to_chars_result to_chars(char* first, char* last,
  float value);

std::to_chars_result to_chars(char* first, char* last,
  double value);

std::to_chars_result to_chars(char* first, char* last,
  long double value);

std::to_chars_result to_chars(char* first, char* last,
  float value, std::chars_format fmt);

std::to_chars_result to_chars(char* first, char* last,
  double value, std::chars_format fmt);

std::to_chars_result to_chars(char* first, char* last,
  long double value, std::chars_format fmt);

std::to_chars_result to_chars(char* first, char* last,
float value, std::chars_format fmt, int precision);

std::to_chars_result to_chars(char* first, char* last,
  double value, std::chars_format fmt, int precision);

std::to_chars_result to_chars(char* first, char* last,
long double value, std::chars_format fmt, int precision);

struct to_chars_result {
    char* ptr;
    std::errc ec;
}; 

El modelo de uso requiere que siempre proporciones dos punteros a char. Considera la conversión de una secuencia de caracteres a un tipo de punto flotante usando from_chars:

#include <charconv>

int main() {
  char a[] = "3.141592";
  float pi;
  std::from_chars_result res = std::from_chars(a, a+9, pi);
  if (res.ec != std::errc{} ) {
    // CONVERSIÓN FALLÓ
  }
}

La función sobrecargada que toma un float se seleccionará y el resultado se colocará en la variable pi.

De manera similar para convertir una variable de punto flotante en una secuencia de caracteres usando to_chars:

#include <charconv>

int main() {
  char a[10];
  float pi = 3.141592f;

  std::to_chars_result res = std::to_chars(a, a+10, pi);
  if (res.ec != std::errc{} ) {
    // CONVERSIÓN FALLÓ
  }
}

Véase la documentación para el valor del miembro ptr cuando se tiene éxito o existe un error.

Aun cuando el modelo de uso es consistente, uno no puede evitar notar que para los casos en los que se conoce el tamaño del array, uno tiene que pasar un puntero de más. ¿Qué tal si en su lugar pudiéramos usar?:

#include "charconvutils.hpp" // plantillas y polvo de hadas

using charconvutils::from_chars, charconvutils::to_chars;

int main() {
  char in[] = "3.141592";
  char out[50]; 
  float pi;

  // Mira, mami, no rastreo el tamaño del array
  std::from_chars_result res1 = from_chars(in, value);
  std::to_chars_result   res2 = to_chars(out, pi);
}

El modelo de uso se simplifica: no tienes que rastrear el tamaño del array. Además, puedes usar otras clases tales como std::array, std::string, std::string_view o std::vector. ¿Interesado? Sigue leyendo.

Plantillas de función al rescate

Examinando cuidadosamente las signaturas de las funciones, hacemos las siguientes observaciones:

  • Todas la funciones toman un par de [const] char* que constituyen un rango válido. En el caso de from_chars, el rango es de sólo lectura, en el caso de to_chars, es donde se ubicará la salida.
  • Las funciones están sobrecargadas para tipos enteros, char y tipos de punto flotante.

Una primera pasada a una plantilla de función para from_chars podría verse así (??? es información que se suministrará posteriormente):

template<std::size_t N, typename T>
std::from_chars result
from_chars(const char(&a)[N], T& value, ???)
{
  return std::from_chars(a, a+N, value, ???);
}

Este cascarón inicial toma ventaja de la deducción de argumentos de plantilla para determinar el tamaño del array. Como el array se pasa por referencia (eso es lo que hace la sintaxis const char(&a)[N), no decae a un puntero, y es lo que queremos.

De manera similar, una primera pasada a una plantilla de función para to_chars podría verse así:

template<std::size_t N, typename T>
std::to_chars result
to_chars(const char(&a)[N], T& value, ???)
{
  return std::to_chars(a, a+N), value, ???);
}

Aquí tienes una versión que no funciona de las plantillas de función usando este enfoque, limitada a from_chars por simplicidad:

// Para tipos enteros
template<std::size_t N, typename T>
std::from_chars_result
from_chars(const char(&a)[N], T& value, int base = 10)
{
  return std::from_chars(a, a + N, value, base);
}

// Para tipos de punto flotante
template<std::size_t N, typename T> 
std::from_chars_result
from_chars(const char(&a)[N], T& value,
           std::chars_format fmt = std::chars_format::general)
{
  return std::from_chars(a, a + N, value, fmt);
}

Ejecutar esta versión de las plantillas de función de cierto modo funciona, pero se rompe cuando utilizas un tipo de punto flotante que no suministra un argumento base, que se initialize por defecto a 10, y por pensar que se instanciará la segunda plantilla de función, pero este no es el caso. Cuando se llaman con el mismo número de argumentos, la primer plantilla de función será instanciada confloat/double/long double, y la llamada a std::from_chars no corresponde.

Invertir las definiciones de las plantillas de función–primero la función para tipos de punto flotante, luego la función para tipos enteros–tampoco funciona.

Lo que queremos es una manera de instanciar la primera plantilla de función cuando pasamos un tipo entero, y la segunda cuando pasamos un tipo de punto flotante. Lo que necesitamos son plantillas y un poquito de polvo de hadas:

#include "charconvutils.hpp" // plantillas y polvo de hadas

using charconvutils::from_chars, charconvutils::to_chars; 

int main() {
  char a[] = "365";
  int daysPerYear;

  // Crea una copia de una plantilla de función para tipos enteros
  std::from_chars_result res1 = from_chars(a, daysPerYear);

  char b[] = "3.141592";
  float pi;

  // Crea una copia de una plantilla de función para tipos
  // de punto flotante
  std::from_chars_result res2 = from_chars(b, pi);
}

Por supuesto, usar la función con parámetros adicionales también permitiría al compilador escoger la función correcta, tal como proporcionar una base para los tipos enteros o fmt para los tipos de punto flotante.

#include "charconvutils.hpp" // plantillas y polvo de hadas

using charconvutils::from_chars, charconvutils::to_chars; 

int main() {
  char a[] = "365";
  int daysPerYear;

  // Crea una copia de una plantilla de función para tipos enteros
  std::from_chars_result res1 = from_chars(a, daysPerYear, 10);

  char b[] = "3.141592";
  float pi;

  // Crea una copia de una plantilla de función para tipos
  // de punto flotante
  std::from_chars_result res2 = from_chars(b, pi);
}

Presentando a enable_if

La solución es usar el rasgo de tipo enable_if, disponible en el archivo de encabezado <type_traits>, o más bien su alias de plantilla enable_if_t. Este rasgo de tipo produce void cuando se le pasa un argumento, o un tipo dado cuando se le pasan dos argumentos y el primero produce true.

El polvo de hadas detrás de enable_if es que si la generación de la copia falla, la plantilla en cuestión no será creada. Estupendo. Al usar otros dos rasgos de tipo, is_integral e is_floating_point, definimos dos alias de plantilla auxiliares que nos permitirán discriminar entre tipos enteros y tipos de punto flotante y seleccionar la plantilla de función apropiada. Un pequeño detalle es que is_integral incluye bool y tipos de carácter además de char, pero para nuestros propósitos, será suficiente–podemos dejar al compilador para que rechace todas las otras copias. Aún más, usaremos los alias de plantilla is_integral_v e is_floating_point_v, disponibles desde C++17, para simplificar.

// Alias de plantilla para tipos enteros
template<typename T>
using EnableIfIntegral = 
  std::enable_if_t<std::is_integral_v<T>>;

// Alias de plantilla para tipos de punto flotante
template<typename T>
using EnableIfFloating =
  std::enable_if_t<std::is_floating_point_v<T>>;


NOTA: El rasgo de tipo std::is_integral incluye bool, tipos de carácter y tipos enteros. Las funciones de conversión std::from_chars y std::to_chars solamente consideran tipos enteros, char y tipos de punto flotante. Ten en mente que el alias de plantilla EnableIfIntegral filtrará cualquier cosa permitida por la plantilla std::is_integral.

Con los alias de plantilla a la mano, definimos dos plantillas de función para la función de conversión de caracteres from_chars (la extenderemos a otros tipos además de arrays de caracteres posteriormente):

template<std::size_t N, typename T, typename=EnableIfIntegral<T>>
std::from_chars_result
from_chars(const char(&a)[N], T& value, int base = 10)
{
  return std::from_chars(a, a + N, value, base);
}

template<std::size_t N, typename T  typename=EnableIfFloating<T>>
std::from_chars_result
from_chars(const char(&a)[N], T& value,
   std::chars_format fmt = std::chars_format::general)
{
  return std::from_chars(a, a + N, value, fmt);
}

De manera similar, definimos tres funciones de plantilla para la función de conversión de caracteres to_chars. Observa que las plantillas de función no tienen un formato o precisión por defecto:

template<std::size_t N, typename T, typename=EnableIfIntegral<T>>
std::to_chars_result
to_chars(char(&a)[N], T value, int base = 10)
{
  return std::to_chars(a, a + N), value, base);
}

template<std::size_t N, typename T, typename=EnableIfFloating<T>> 
std::to_chars_result
to_chars(char(&a)[N], T value, std::chars_format fmt)
{
  return std::to_chars(a, a + N), value, fmt);
}

template<std::size_t N, typename T, typename=EnableIfFloating<T>>  
std::to_chars_result
to_chars(char(&a)[N], T value, std::chars_format fmt,
         int precision)
{
    return std::to_chars(a, a + N), value, fmt, precision);
}

Extender a std::array

Es fácil extender la funcionalidad a std::array. Las plantillas de función from_chars y to_chars correspondientes para std::array son directas. Aquí tienes un par de ejemplos para tipos enteros:

template<std::size_t N, typename T, typename=EnableIfIntegral<T>>
std::from_chars_result
from_chars(const std::array<char, N>& a, T& value, int base = 10)
{
  return std::from_chars(a.data(), a.data() + N, value, base);
}

template<std::size_t N, typename T, typename=EnableIfIntegral<T>> 
std::to_chars_result
to_chars(std::array<char, N>& a, T value, int base = 10)
{
  return std::to_chars( a.data(), a.data() + N, value, base);
}

Extender a contenedores de acceso aleatorio

En Programación Genérica y la STL, Matt Austern describe los contenedores de acceso aleatorio. El libro se basa en la documentación de SGI STL (o viceversa) y Martin Broadhurst amablemente la conservó aquí. Puedes encontrar la descripción de contenedores de acceso aleatorio en inglés aquí. Diciendo esto, std::string, std::vector, std::deque y std::array son contenedores de acceso aleatorio que podrían usarse como la fuente o el destino de from_chars and to_chars. Simplemente asigna el almacenamiento necesario para la conversión por adelantado. std::string_view, aunque estrictamente hablando no es un contenedor de acceso aleatorio, califica porque proporciona la misma interfaz, accediendo a su std::string subyacente. Por supuesto, uno deberá garantizar que los contenedores no cambiarán mientras la conversión toma lugar.

Con las definiciones que siguen, las plantillas de función para std::array definidas anteriormente pueden eliminarse y reemplazarse.

La plantilla de función calcula el rango en tiempo de ejecución utilizando data() y size(). Además, hay una comprobación para evitar pasar contenedores cuyo tipo de valor no es char. Aquí tienes un par de ejemplos para tipos enteros:

template<typename Cont, typename T, typename=EnableIfIntegral<T>>
std::from_chars_result
from_chars(const Cont& c, T& value, int base = 10)
{
  static_assert(std::is_same_v<char, typename Cont::value_type>,
                "Container value type must be char.");
  return std::from_chars(c.data(), c.data() + c.size(), 
                         value, base);
}

template<typename Cont, typename T, typename=EnableIfIntegral<T>> 
std::to_chars_result
to_chars(Cont& c, T value, int base = 10)
{
  static_assert(std::is_same_v<char, typename Cont::value_type>,
                "Container value type must be char.");
  return std::to_chars(c.data(), c.data() + c.size(),
                       value, base);
}

En resumen

Aquí tienes el archivo "charconvutils.hpp" completo:

#ifndef CHARCONVUTILS_HPP
#define CHARCONVUTILS_HPP

#include <charconv>
#include <type_traits>

namespace charconvutils {

// -----
// Alias de plantilla asistentes

template<typename T>
using EnableIfIntegral = 
  std::enable_if_t<std::is_integral_v<T>>;

template<typename T>
using EnableIfFloating = 
std::enable_if_t<std::is_floating_point_v<T>>;

// -----
// plantillas de función de from_chars para arrays estilo C

template<std::size_t N, typename T,
         typename =  EnableIfIntegral<T>>
std::from_chars_result
from_chars(const char(&a)[N], T& value, int base = 10) {
  return std::from_chars(a, a + N, value, base);
}

template<std::size_t N, typename T, 
         typename =  EnableIfFloating<T>>
std::from_chars_result
from_chars(const char(&a)[N], T& value,
           std::chars_format fmt = std::chars_format::general) {
  return std::from_chars(a, a + N, value, fmt);
}

// -----
// plantillas de función de from_chars para contenedores de
// acceso aleatorio

template<typename Cont, typename T,
         typename = EnableIfIntegral<T>>
std::from_chars_result
from_chars(const Cont& c, T& value, int base = 10) {
  static_assert(std::is_same_v<char, typename Cont::value_type>, 
                "Tipo de valor del contenedor debe ser char.");
  return std::from_chars(c.data(), c.data() + c.size(),
                         value, base);
}

template<typename Cont, typename T,
         typename = EnableIfFloating<T>>
std::from_chars_result
from_chars(const Cont& c, T& value,
           std::chars_format fmt = std::chars_format::general) {
  static_assert(std::is_same_v<char, typename Cont::value_type>, 
                "Tipo de valor del contenedor debe ser char.");
  return std::from_chars(c.data(), c.data() + c.size(),
                         value, fmt);
}

// -----
// plantillas de función de to_chars para arrays estilo C

template<std::size_t N, typename T,
         typename = EnableIfIntegral<T>>
std::to_chars_result
to_chars(char(&a)[N], T value, int base = 10) {
  return std::to_chars(a, a + N, value, base);
}

template<std::size_t N, typename T,
         typename = EnableIfFloating<T>>
std::to_chars_result
to_chars(char(&a)[N], T value, std::chars_format fmt) {
  return std::to_chars(a, a + N, value, fmt);
}

template<std::size_t N, typename T,
         typename = EnableIfFloating<T>>
std::to_chars_result
to_chars(char(&a)[N], T value, std::chars_format fmt,
         int precision) {
  return std::to_chars(a, a + N, value, fmt, precision);
}

// -----
// plantillas de función de to_chars para contenedores
// de acceso aleatorio

template<typename Cont, typename T,
         typename = EnableIfIntegral<T>>
std::to_chars_result
to_chars(Cont& c, T value, int base = 10) {
  static_assert(std::is_same_v<char, typename Cont::value_type>, 
                "Tipo de valor del contenedor debe ser char.");
  return std::to_chars(c.data(), c.data() + c.size(),
                       value, base);
}

template<typename Cont, typename T,
         typename = EnableIfFloating<T>>
std::to_chars_result
to_chars(Cont& c, T value, std::chars_format fmt) {
  static_assert(std::is_same_v<char, typename Cont::value_type>, 
                "Tipo de valor del contenedor debe ser char.");
  return std::to_chars(c.data(), c.data() + c.size(),
                       value, fmt);
}

template<typename Cont, typename T,
         typename = EnableIfFloating<T>> 
std::to_chars_result
to_chars(Cont& c, T value, std::chars_format fmt, int precision) {
  static_assert(std::is_same_v<char, typename Cont::value_type>,
                "Tipo de valor del contenedor debe ser char.");
  return std::to_chars(c.data(), c.data() + c.size(),
                       value, fmt, precision);
}

} // namespace charconvutils

#endif // CHARCONVUTILS_HPP

Puedes descargar este archivo (con comentarios en inglés) en GitHub.

Notas posteriores

Jens Maurer propuso las funciones de conversión de caracteres en esta ponencia. Es interesante ver las interfaces propuestas, y no pude encontrar por qué no se consideraron plantillas (aunque se consideró std::string_view). Quizás en una iteración distinta de la ponencia la idea se rechazó y pasé algo por alto (p. ej., consideraciones para que los contenedores no sean accedidos por múltiples hilos mientras la conversión toma lugar).

¿Qué tal vínculos estructurados? Seguro, puedes usar vínculos estructurados para capturar la estructura resultante–como se mencionó previamente, Nicolai Josuttis tiene varios ejemplos.

Otro aspecto que parece faltar es, ¿si se declara que las funciones no lanzan excepciones, por qué no marcarlas con noexcept? Eso es lo que Microsoft hizo, y parece lógico. ¿Quizás una consideración es que el rango pueda volverse inválido mientras se realiza la conversión?

En resumen, este artículo exploró cómo se puede mejorar el modelo de uso para las funciones de conversión de caracteres invirtiendo en plantillas de función.

Si quieres aprender más sobre las nuevas características de C++17, adquiere el libro electrónico o en Amazon. Los enlaces los puedes encontrar aquí.

This entry was posted in C++, C++17, software and tagged , , , , . Bookmark the permalink.

1 Response to Una historia de conversión: mejorando from_chars y to_chars en C++17

  1. Pingback: A Conversion Story: Improving from_chars and to_chars in C++17 | Se Habla C++

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s