Concepts y Constraints: Domina la restricción de tipos

Un concept es un nombre para un conjunto de requisitos que un tipo debe cumplir para ser utilizado en una plantilla. En términos técnicos, es un predicado que se evalúa en tiempo de compilación para determinar si un tipo es “válido” para una función o clase específica.

Antes de C++20, la forma de restringir plantillas era mediante el uso de SFINAE (Substitution Failure Is Not An Error) y la biblioteca std::enable_if. Eso era un caos: el compilador intentaba instanciar la plantilla, fallaba de forma extraña y lanzaba errores de cientos de líneas que apenas explicaban qué tipo había fallado. Con los concept, le damos al compilador una semántica clara: ya no estamos “trucando” el sistema de tipos con errores controlados, estamos definiendo contratos explícitos.

Esto funciona así porque los concept se integran profundamente en el sistema de tipos. Cuando el compilador encuentra una restricción, no solo comprueba si el tipo “encaja”, sino que verifica si cumple con la semántica (como si un tipo es std::integral o std::floating_point) y la sintaxis (si un objeto tiene un método .size()). Debes usar concept siempre que diseñes código genérico donde no quieras que el usuario pase tipos absurdos que solo causen errores crípticos dentro del cuerpo de la función. Si no los usas y el usuario pasa un tipo incompatible, el error ocurrirá en el punto donde se use la funcionalidad prohibida, no en el punto de la llamada, lo que hace que el debugging sea una pesadilla.

#include <iostream>
#include <concepts>
#include <vector>
#include <string>
#include <type_traits>

// 1. Definición de un concepto personalizado usando un 'requires expression'
// Comprobamos si el tipo T tiene un método .size() que devuelva algo convertible a std::size_t
template<typename T>
concept EsContenedor = requires(T t) {
    { t.size() } -> std::convertible_to<std::size_t>;
};

// 2. Uso de un concepto como restricción en el parámetro (Sintaxis abreviada)
// Solo aceptará tipos que cumplan la semántica de integral (int, long, etc.)
template<std::integral T>
T sumar(T a, T b) {
    return a + b;
}

// 3. Uso de una cláusula 'requires' para combinar conceptos y expresiones
// El tipo debe ser un contenedor Y debe ser copyable para evitar problemas de memoria
template<typename T>
requires EsContenedor<T> && std::copyable<T>
void imprimir_info(const T& contenedor) {
    std::cout << "Contenedor con " << contenedor.size() << " elementos.\n";
}

// 4. Uso de una cláusula 'requires' con una expresión directa (sin nombre de concepto)
// Comprueba si el tipo T soporta el operador >> (ej. para streams)
template<typename T>
requires requires(T t) { t >> std::cout; }
void enviar_a_consola(T t) {
    std::cout << t << std::endl;
}

int main() {
    // Casos válidos
    int i = 10;
    std::vector<int> v = {1, 2, 3, 4, 5};
    std::string s = "Hola C++20";

    std::cout << "Suma: " << sumar(10, 20) << "\n"; // Funciona: int es integral
    imprimir_info(v);                             // Funciona: vector es contenedor y copyable
    imprimir_info(s);                             // Funciona: string es contenedor y copyable
    enviar_a_consola(i);                          // Funciona: int soporta el operador >>

    // Casos que darían error de compilación (descomenta para ver el error)
    // sumar(3.14, 2.0);          // ERROR: floating_point no es integral
    // imprimir_info(42);         // ERROR: int no cumple EsContenedor
    // enviar_a_consola(v);       // ERROR: std::vector no soporta operator>>
    
    return 0;
}

Desglose del código

En la definición de EsContenedor, estamos usando un requires expression. La sintaxis { t.size() } -> std::convertible_to<std::size_t>; es una compound requirement (requisito compuesto). No solo verifica que la expresión t.size() sea válida (que el método exista y sea invocable), sino que también impone una restricción sobre el tipo de retorno: debe ser convertible a std::size_t. Si size() devolviera void, el concepto fallaría.

En la función sumar, aplicamos la sintaxis abreviada de restricción (template<std::integral T>). Esto es equivalente a escribir template<typename T> requires std::integral<T>. El compilador utiliza los metadatos de la biblioteca estándar para validar que T sea un tipo integral antes de intentar compilar el cuerpo de la función.

La función imprimir_info utiliza una cláusula requires para combinar dos conceptos: EsContenedor<T> (nuestro concepto personalizado) y std::copyable<T> (de la biblioteca estándar). Esto es crucial para la seguridad: un tipo podría tener un método .size() pero no ser copiable (por ejemplo, un contenedor que gestiona recursos únicos), y la cláusula requires impide que esto cause errores de compilación inesperados dentro de la lógica de la función.

Finalmente, enviar_a_consola muestra cómo podemos usar requires directamente con una expresión para verificar una propiedad ad-hoc (el operador de flujo >>) sin necesidad de declarar un concept formal. Esto es útil para requisitos muy específicos que no merecen tener un nombre propio.

El error frecuente

Un error muy común al usar requires expression es ser “demasiado laxo”. Si defines un concepto así:

template<typename T>
concept TieneMetodo = requires(T t) {
    t.foo(); // Solo comprueba que la sintaxis es válida
};

Si pasas un tipo donde foo() devuelve void, el concepto TieneMetodo seguirá siendo válido. El problema es que si tu función genérica intenta usar el resultado de t.foo(), como en auto x = t.foo();, el compilador lanzará un error de tipo dentro del cuerpo de la función en lugar de darte un error limpio en la llamada. La solución siempre es usar compound requirements (como vimos en EsContenedor) para validar explícitamente el tipo de retorno.

55

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio