Inferencia automática de tipos (auto)

Última actualización: 15 de agosto de 2020.

Artículos de la serie:

Introducción


La inferencia automática de tipos mediante la palabra clave auto --técnica introducida por vez primera en el estándar ISO C++ 2011 y refinada en estándares posteriores [1]-- permite la declaración de variables de forma tal que sus tipos sean deducidos automáticamente a partir de los tipos de sus inicializadores, siguiendo las reglas clásicas de deducción de argumentos en plantillas de función (template-argument-deduction o TAD) [2]. A partir de C++14, la inferencia automática puede ser también empleada en la signatura de una función para requerir que el tipo retornado sea deducido a partir de sus instrucciones de retorno.

Esta potente herramienta de programación fue diseñada por Bjarne Stroustrup a principios de los años 1980, pero tuvo que aguardar hasta su inclusión definitiva en C++11 por motivos de compatibilidad con el lenguaje C.

Por ejemplo, en sustitución de

   std::unique_ptr<int> p = std::make_unique<int>(1);

podemos escribir, sin riesgo de introducir ambigüedad en el código:

   auto p = std::make_unique<int>(1);

de forma que el compilador infiera de forma automática el tipo del puntero inteligente p a partir del tipo retornado por la función std::make_unique<int>, en este caso std::unique_ptr<int>.


Reglas de inferencia


Como indicábamos en la introducción, la inicialización de variables a través del especificador auto sigue las reglas de TAD para la deducción de tipos [2], salvo en casos excepcionales que mencionaremos posteriormente que involucran listas de inicializadores entre llaves {}.

En los ejemplos que desarrollaremos en esta sección trabajaremos con dos plantillas de función f y g que operan mediante paso por valor y paso por referencia, respectivamente (este estudio se inspira en las referencias [3, 4], cuya lectura se recomienda):

       template<typename T>    void f(T t);  // paso por valor    template<typename T>    void g(T& t);    // paso por referencia

Asimismo, haremos uso recurrente de las siguientes definiciones:

       double a{1.0};    // double no-constante    double& b{a};   // referencia a double no-constante    double const c{2.0};   // double constante    double const& d{a};   // referencia a double constante    double* p{&a};  // puntero a double no-constante    double* const q{&a};  // puntero constante a double no-constante    double const* r{&a};  // puntero a double constante    double constconst s{&a}; // puntero constante a double constante

Denote E una expresión, como por ejemplo el nombre identificador de una variable o la desreferencia de un puntero. Analizaremos en primer lugar las deducciones TAD para las siguientes llamadas a función f(E) con paso por valor, comparándolas con los tipos obtenidos en inicializaciones de la forma auto var = E:

 f(a);  // T = double, t == a   f(b);  // T = double, t == a   f(c);  // T = double, t == c   f(d);  // T = double, t == a   f(p);  // T = double*, t == p   f(*p); // T = double, t == a   f(q);  // T = double*, t == q   f(*q); // T = double, t == a   f(r);  // T = double const*, t == r   f(*r); // T = double, t == a   f(s);  // T = double const*, t == s   f(*s); // T = double, t == a 


  auto i = a;  // decltype(i) = double, i == a   auto j = b;  // decltype(j) = double, j == a   auto k = c;  // decltype(k) = double, k == c   auto l = d;  // decltype(l) = double, l == a   auto o = p;  // decltype(o) = double*, o == p   auto t = *p; // decltype(t) = double, t == a   auto u = q;  // decltype(u) = double*, u == q   auto v = *q; // decltype(v) = double, v == a   auto w = r;  // decltype(w) = double const*, w == r   auto x = *r; // decltype(x) = double, x == a   auto y = s;  // decltype(y) = double const*, y == s   auto z = *s; // decltype(z) = double, z == a

Como podemos observar, en una cualquiera de las llamadas f(E) anteriores, la deducción del parámetro T a partir del tipo y categoría de valor proporcionados por decltype(E)tiene lugar a través de dos acciones sucesivas (consúltese [5, 6] para un análisis más detallado):
  1. Si decltype(E) contiene una referencia, ésta es eliminada. Así ocurre con bd, *p, *q, *r y *s (en el caso de la desreferencia de punteros recordemos, por ejemplo, que decltype(*p) = double& [7]).
  2. A continuación, todo calificador adicional const-volatile de primer nivel es ignorado. Así sucede con c, d, q*rs y *s.
Por ejemplo, en la llamada f(*s) se tiene que decltype(*s) = double  const&, por lo que el tipo T es simplemente deducido como double tras la eliminación de la referencia & y el calificador const.

El bloque de código anterior demuestra que el mismo comportamiento tiene lugar con auto, punto éste que puede comprobarse utilizando aserciones estáticas y dinámicas como las siguientes:

   static_assert(std::is_same_v<decltype(y), double const*>); // verdadero    assert(y == s); // verdadero

En contraste con el análisis anterior, consideremos ahora la plantilla de función g, la cual no opera mediante paso por valor sino a través de una referencia (podríamos también realizar las pruebas, a igual efecto, con un puntero). TAD procederá de la forma siguiente en la deducción del tipo T [5]:
  1. En primer lugar, y como en el caso de paso por valor, cualquier posible referencia en decltype(E) será eliminada.
  2. Los posibles calificadores const-volatile remanentes serán en este caso respetados.
Lógicamente, el tipo de la variable t en la plantilla g será T&. Ese mismo tipo es el obtenido a través de inicializaciones de la forma auto& var = E:

      g(a);  // T = double     g(b);  // T = double     g(c);  // T = double const     g(d);  // T = double const     g(p);  // T = double*     g(*p); // T = double     g(q);  // T = double* const     g(*q); // T = double     g(r);  // T = double const*    g(*r); // T = double const     g(s);  // T = double const* const    g(*s); // T = double const 
      auto& i = a;  // decltype(i) = double&   auto& j = b;  // decltype(j) = double&   auto& k = c;  // decltype(k) = double const&   auto& l = d;  // decltype(l) = double const&   auto& o = p;  // decltype(o) = double*&   auto& t = *p; // decltype(t) = double&   auto& u = q;  // decltype(u) = double* const&   auto& v = *q; // decltype(v) = double&   auto& w = r;  // decltype(w) = double const*&   auto& x = *r; // decltype(x) = double const&   auto& y = s;  // decltype(y) = double const* const&   auto& z = *s; // decltype(z) = double const&

Si meditamos sobre ello, concluiremos que las reglas de deducción seguidas por las plantillas de función son lógicas. En el caso de paso por valor, carecería de utilidad mantener un posible calificador const. En efecto, la función va a operar con su propia copia independiente del argumento y, de ser declarada constante, las operaciones con ella permitidas se verían sustancialmente limitadas. Por su parte, en el caso de paso por referencia, resulta crucial que el posible carácter constante de la variable referenciada sea respetado. Como hemos comprobado, auto sigue estas mismas reglas.

Por supuesto, tanto en el caso de paso por valor como en el de paso por referencia, el programador podría enriquecer el parámetro de la función con calificadores const-volatile adicionales:

       template<typename T>    void fc(T const t);    template<typename T>    void gc(T const& t);

Como en ejemplos anteriores, el tipo de t será naturalmente deducido en cada caso, bien como copia constante (para fc) o como referencia a objeto constante (para gc):

      fc(a);  // decltype(t) = double const    fc(b);  // decltype(t) = double const      fc(c);  // decltype(t) = double const     fc(d);  // decltype(t) = double const     fc(p);  // decltype(t) = double* const     fc(*p); // decltype(t) = double const     fc(q);  // decltype(t) = double* const     fc(*q); // decltype(t) = double const    fc(r);  // decltype(t) = double const* const    fc(*r); // decltype(t) = double const      fc(s);  // decltype(t) = double const* const    fc(*s); // decltype(t) = double const 
      gc(a);  // decltype(t) = double const&   gc(b);  // decltype(t) = double const&   gc(c);  // decltype(t) = double const&   gc(d);  // decltype(t) = double const&   gc(p);  // decltype(t) = double* const&   gc(*p); // decltype(t) = double const&   gc(q);  // decltype(t) = double* const&   gc(*q); // decltype(t) = double const&   gc(r);  // decltype(t) = double const* const&   gc(*r); // decltype(t) = double const&   gc(s);  // decltype(t) = double const* const&   gc(*s); // decltype(t) = double const&

Los tipos T deducidos por la pareja de funciones f y fc (o por g y gc) son los mismos en cada caso. El tipo de la variable t será T (siempre no-constante) en f y T const (siempre constante) en fc. En el caso de gc, si el tipo T es constante, el tipo de t es T&, mientras que si T es no-constante, el tipo de t es T const&.

De la misma manera, auto podrá también acompañarse de los modificadores const-volatile a conveniencia con el fin de hacerlos participar en la deducción de tipos. Por ejemplo:

   auto a = 1.0;       // decltype(a) = double    auto b = a;         // decltype(b) = double, b == a    auto const c = a;   // decltype(c) = double const, c == a    auto const& d = a;       // decltype(d) = double const&    auto& e = c;  // decltype(e) = double const&    auto p = &a;        // decltype(p) = double*    auto const q = &a;  // decltype(q) = double* const    auto const* r = &a;  // decltype(r) = double const*    auto const* const s = &a; // decltype(s) = double const* const

Debe ser el programador, en última instancia, quien decida la conveniencia de descansar en la inferencia automática de tipos en casos tan sencillos como los señalados. Conviene señalar aquí la existencia de una tendencia declarativa dentro del lenguaje, conocida como estilo '(almost) always auto' [8], que insta a inicializar las variables a través del placeholder auto en cualquier caso, sin que ello signifique renunciar a indicar explícitamente sus tipos. Para más información, consúltese el post de este blog de título Always auto: Una sintaxis moderna para C++.


auto vs auto& vs auto const&


Consideremos la función miembro pública value_type& operator[](size_type) del contenedor std::vector, la cual retorna una referencia lvalue. Nos serviremos del siguiente código de ejemplo para resaltar una vez más el hecho de que, en ausencia del operador &auto intentará capturar por valor (es decir, copiar) los valores retornados por funciones como la indicada:

   std::vector<unsigned long long> v{1ull, 5ull, 7ull};    auto a = v[0];  // a es una copia independiente de v[0]    ++a;  // a vale ahora 2ull; v[0] sigue siendo 1ull    auto& b = v[0];  // b es una referencia a v[0]    b = 0ull;  // v[0] pasa a valer 0ull    auto const& c = v[0]; // c es una referencia de sólo lectura a v[0]    c = 1ull; // error de compilación

Esto es especialmente relevante al emplear bucles for basados en rango:

   std::vector<unsigned long long> v{1ull5ull7ull};    for (auto i : v) { /* copias modificables de los elementos de v */ }    for (auto const i : v) { /* copias no modificables de los elementos de v */ }    for (auto& i : v) { /* acceso por referencia a los elementos de v */ }    for (auto const& i : v) { /* referencia constante a los elementos en v */ }

Observemos que en el caso de for (auto& i : v), las referencias serían a objeto constante si el propio vector v fuera constante. En efecto, tal y como discutimos en la sección anterior, el tipo deducido por auto& respetará el posible calificador const del objeto referenciado.


Diferencias entre auto y TAD


Llegados a este punto, mencionaremos una discrepancia entre el funcionamiento de auto y TAD cuando ponemos en juego listas de inicializadores entre llaves {}. Observemos que un brace-init-list como {1, 2, 3} no constituye una expresión por sí misma, por lo que decltype({1, 2, 3}) no se encuentra definido [9]. En consecuencia, dada la plantilla de función template<typename T> f(T) de los ejemplos anteriores, una llamada como la siguiente conduciría necesariamente a un error de compilación, al no ser posible deducir un tipo para el parámetro T

   f({1, 2, 3}); // error de compilación: template argument deduction failed

auto, sin embargo, es capaz de deducir tipos a partir de brace-init-lists. En el caso de copy-list-initializations como los siguientes, se generarán objetos std::initializer_list:

   auto a = {7.0}; // decltype(a) = std::initializer_list<double>    auto b = {123}; // decltype(b) = std::initializer_list<int>

En el caso de un direct-list-initialization, y siempre que el brace-init-list contenga sólo un valor, la variable inicializada adquirirá el tipo deducido a partir del elemento entre llaves. Si la lista contuviese más de un elemento, se produciría un error de compilación:

   auto a{7.0}; // decltype(a) = double (desde C++17)    auto b{1, 2, 3}; // error de compilación


Algunos ejemplos de uso


Una de las utilidades más evidentes de la inferencia automática de tipos consiste en simplificar la labor del programador y mejorar la legibilidad del código cuando el tipo de la variable a deducir no sea trivial, pero sí evidente por el contexto. Así, por ejemplo, con el fin de mostrar en consola los valores acumulados en el siguiente vector, podríamos seguir un esquema basado en iteradores propio de C++98 y escribir:

   std::vector<long double> v{0.5L, 1.2L, 2.5L, 3.9L, 4.8L};    for (std::vector<long double>::const_iterator p = v.cbegin(); p != v.cend(); ++p)       std::cout << *p << '\n';

En sustitución del bucle anterior, sin embargo, podríamos servirnos de la inferencia automática de tipos de manera que el compilador determine automáticamente el tipo del iterador p:

   for (auto p = v.cbegin(); p != v.cend(); ++p)       std::cout << *p << '\n';

Por supuesto, el código anterior podría simplificarse aún más mediante el empleo de un bucle for basado en rango (C++11 y posteriores):

   for (auto const& d : v)       std::cout << d << '\n';

Observemos la desaparición del operador * de desreferencia al trabajar ahora directamente con referencias a los elementos del vector.

auto puede también evitar errores comunes de programación, en particular la conversión peligrosa de tipos (v es aquí el vector definido en el ejemplo anterior):

   int const sz = v.size();  // ¡cuidado! size() retorna un entero sin signo    auto const sz = v.size(); // Ok! sz es de tipo std::vector<long double>::size_type const

Asimismo, auto resulta imprescindible para declarar variables con nombre de tipo desconocido por el programador, como las expresiones lambda (véase esta serie de artículos para más detalles):

   auto same_level = [](Enemy const& e_1, Enemy const& e_2){ return e_1.level == e_2.level; };


auto como tipo de retorno en funciones


La palabra clave auto puede también emplearse en funciones y plantillas de función con el fin de deducir el tipo retornado a través de sus expresiones de retorno. En este caso, auto seguirá estrictamente las reglas de TAD, de forma que si la sentencia de retorno usara un brace-init-list, se produciría un error de compilación [10]:

   auto f() { return {1, 2, 3}; } // error de compilación: returning initializer list

Este tipo de inferencia automática resulta especialmente útil en programación genérica. Consideremos, por ejemplo, la siguiente plantilla de función:

   template<typename T, typename U>    auto add(T const& a, U const& b)    {       return a + b;    }

Observemos que, en general, los tipos T y U serán distintos, por lo que no es posible conocer a priori el tipo resultante de la suma de a y b. La inferencia automática resuelve esta dificultad, al ser capaz de determinar el tipo de la expresión operator+(a,b) en tiempo de compilación.


Referencias bibliográficas
  1. Cppreference - Placeholder type specifiers - https://en.cppreference.com/w/cpp/language/auto
  2. Cppreference - Template argument deduction - https://en.cppreference.com/w/cpp/language/template_argument_deduction
  3. Scott Meyers - Appearing and disappearing consts in C++ - https://aristeia.com/Papers/appearing%20and%20disappearing%20consts.pdf
  4. Herb Sutter - Auto variables Part 1 - https://herbsutter.com/2013/06/07/gotw-92-solution-auto-variables-part-1/
  5. Scott Meyers, Effective Modern C++. O'Reilly Media (2014).
  6. ACCU - The C++ Template Argument Deduction - https://accu.org/index.php/journals/409#ftn.d0e1029
  7. Cppreference - decltype specifier - https://en.cppreference.com/w/cpp/language/decltype
  8. Herb Sutter - AAA style - https://herbsutter.com/2013/08/12/gotw-94-solution-aaa-style-almost-always-auto/
  9. Cppreference - List initialization - https://en.cppreference.com/w/cpp/language/list_initialization
  10. Cppreference - Return type deduction - https://en.cppreference.com/w/cpp/language/function#Return_type_deduction

No hay comentarios:

Publicar un comentario