std::variant [C++17] es un contenedor de tipo “suma” (sum type) que representa una unión de tipos segura para el tipo (type-safe union). A diferencia de una union tradicional de C, std::variant sabe qué tipo contiene en un momento dado y garantiza que no se acceda a un objeto de forma incorrecta, lanzando std::bad_variant_access si se intenta obtener el valor equivocado. Para trabajar con std::variant de forma elegante, el patrón overloaded nos permite tratar múltiples lambdas como un único objeto ejecutable, permitiendo un despacho de llamadas basado en tipos sin la verbosidad de definir una estructura de clase con múltiples operator().
Este patrón funciona mediante herencia variádica. La estructura overloaded hereda de un paquete de tipos Ts..., donde cada tipo es una expresión lambda. Al usar using Ts::operator()...; (expansión de paquetes en una declaración using), traemos todos los operadores de llamada de las lambdas heredadas al ámbito de overloaded. Esto permite que el compilador vea todas las sobrecargas de operator() simultáneamente. El patrón es ideal cuando necesitas implementar un visitor (visitante) de forma rápida y local, especialmente en máquinas de estados o en el procesamiento de tipos complejos donde un switch tradicional con std::holds_alternative resultaría ineficiente y propenso a errores.
Debes usarlo siempre que el estado de tu sistema sea disjunto: o estás en un estado A, o en un B, pero nunca en ambos. Es la herramienta principal para implementar máquinas de estados mediante tipos, donde las transiciones de estado son funciones que reciben un std::variant y devuelven un nuevo std::variant. Si intentas usarlo omitiendo un tipo contenido en el variant dentro de tu visitor, el compilador lanzará un error masivo e incomprensible intentando expandir las plantillas; la única forma de evitarlo es mediante un “catch-all” usando auto.
Si fallas al definir los tipos de retorno en un std::visit que busca retornar un valor, el código no compilará. Todas las lambdas del visitor deben retornar un tipo compatible con el mismo tipo de retorno esperado por el compilador. Si una lambda devuelve int y otra std::string, std::visit fallará porque no puede determinar un tipo común de retorno para la expresión.
#include <iostream>
#include <string>
#include <variant>
#include <vector>
// Definición de estados para una máquina de estados de conexión
struct Disconnected {};
struct Connecting { int attempts; };
struct Connected { std::string ip_address; };
struct Error { std::string message; };
// Usamos std::monostate [C++17] como estado inicial "vacío" o por defecto
using ConnectionState = std::variant<std::monostate, Disconnected, Connecting, Connected, Error>;
// El patrón overloaded para agrupar lambdas en un solo visitor
template<typename... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
// Deduction guide para permitir CTAD (C++17)
template<typename... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
// Función de transición de estados
ConnectionState process_event(ConnectionState current) {
return std::visit(overloaded{
// Transición: Disconnected -> Connecting
[](Disconnected) -> ConnectionState {
return Connecting{1};
},
// Transición: Connecting -> Connected (si intentos < 3)
[](Connecting c) -> ConnectionState {
if (c.attempts < 3) return Connecting{c.attempts + 1};
return Error{"Max retries reached"};
},
// Transición: Connecting -> Error (caso extremo)
[](auto) -> ConnectionState {
// El catch-all 'auto' captura Connected, Error y monostate
// en este ejemplo para simplificar la lógica de error.
return Error{"Transition failed"};
}
}, current);
}
int main() {
ConnectionState state = std::monostate{};
// Simulamos un ciclo de vida de conexión
for (int i = 0; i < 5; ++i) {
std::cout << "Estado actual: ";
// std::visit para inspeccionar el estado actual
std::visit(overloaded{
[](std::monostate) { std::cout << "Init\n"; },
[](Disconnected) { std::cout << "Disconnected\n"; },
[](Connecting c) { std::cout << "Connecting (attempt " << c.attempts << ")\n"; },
[](Connected c) { std::cout << "Connected to " << c.ip_address << "\n"; },
[](Error e) { std::cout << "Error: " << e.message << "\n"; }
}, state);
state = process_event(state);
if (std::holds_alternative<Error>(state)) break;
}
// Ejemplo de visita con múltiples variants [C++17]
ConnectionState state2 = Connected{"127.0.0.1"};
ConnectionState state3 = Error{"Timeout"};
std::cout << "\nComparando estados:\n";
std::visit(overloaded{
[](auto&& s1, auto&& s2) {
// Este visitor se puede usar para comparar estados complejos
// Si el compilador no puede deducir el tipo de retorno,
// se puede forzar en C++23 con std::visit<RetType>(...)
std::cout << "Evaluando interacción entre estados...\n";
}
}, state2, state3);
return 0;
}
Análisis del código
En la estructura overloaded, la línea using Ts::operator()...; es crucial. Sin ella, aunque heredemos de las lambdas, el compilador no consideraría que overloaded tiene múltiples operator() sobrecargados, sino que habría una ambigüedad en la herencia. Al usar la expansión de paquetes, estamos inyectando todas las llamadas de las lambdas en el mismo ámbito.
En process_event, observamos cómo std::visit se utiliza para implementar una transición de máquina de estados. Fíjate en la firma [](Connecting c) -> ConnectionState. En C++, cuando usas std::visit para retornar un valor, el compilador infiere el tipo de retorno de la expresión completa basándose en el tipo común de lo que devuelven todas las lambdas. En este caso, forzamos el retorno a ConnectionState para que el compilador sea explícito y nos ayude a evitar errores de inferencia si las lambdas fueran más complejas.
En el main, la primera llamada a std::visit usa lambdas que no devuelven nada (devuelven void). Aquí, std::visit simplemente ejecuta la función correspondiente. La segunda llamada en el bucle for es la que transforma el estado: state = process_event(state);. Esto es posible porque std::visit puede devolver un objeto del mismo tipo que el variant original, permitiendo una composición funcional de estados.
Finalmente, la visita de múltiples variants en la línea std::visit(overloaded{...}, state2, state3); permite comparar o combinar información de dos objetos diferentes simultáneamente. El compilador genera una tabla de salto que cubre todas las combinaciones posibles de tipos contenidos en ambos variants (en este caso, una combinación de Connected y Error).
El error frecuente
Un error clásico al usar el patrón overloaded es olvidar la consistencia en los tipos de retorno. Imagina este escenario:
// ERROR: El compilador fallará con un error de template masivo
std::visit(overloaded{
[](int i) { return std::to_string(i); }, // Devuelve std::string
[](double d) { return d; } // Devuelve double
}, mi_variant);
Aquí, std::visit no sabe qué tipo debe devolver la expresión. Aunque double y std::string podrían parecer “convertibles” en otros contextos, std::visit exige que el tipo de retorno sea consistente para poder construir la tabla de despacho. Si estás en C++23, puedes mitigar la ambigüedad de tipos complejos usando std::visit<RetType>(visitor, variant), pero la mejor práctica es asegurar que todas las ramas del overloaded devuelvan el mismo tipo o tipos que puedan convertirse a un tipo común sin ambigüedad.
N° 143