Los templates de C++ son potentes, pero sin constraints producen errores de compilación que pueden ocupar pantallas enteras de texto ilegible. Cuando pasas un tipo incorrecto a un template basado en SFINAE, el compilador te muestra el error en la instanciación interna, no en el lugar donde cometiste el error. Con concepts, el error aparece exactamente donde se viola la restricción y dice exactamente qué falta.
// Sin concepts: si T no tiene operator+, el error aparece dentro del template
template <typename T>
T sumar(T a, T b) { return a + b; }
// Con concepts: el error aparece en el punto de llamada
template <typename T>
requires std::is_arithmetic_v<T>
T sumar(T a, T b) { return a + b; }
Definir un concept
Un concept es una expresión booleana en tiempo de compilación que describe los requisitos de un tipo. Se define con la palabra clave concept:
#include <concepts>
// Concept que acepta cualquier tipo numérico
template <typename T>
concept Numerico = std::is_arithmetic_v<T>;
// Concept más elaborado: tipos que tienen begin(), end() y size()
template <typename T>
concept Coleccion = requires(T t) {
t.begin();
t.end();
{ t.size() } -> std::convertible_to<std::size_t>;
};
La cláusula requires
Hay varias formas de añadir una restricción a un template:
// Forma 1: requires clause tras la lista de parámetros
template <typename T>
requires Numerico<T>
T cuadrado(T x) { return x * x; }
// Forma 2: el concept directamente como tipo del parámetro
template <Numerico T>
T cuadrado(T x) { return x * x; }
// Forma 3: abbreviated template syntax (la más compacta)
Numerico auto cuadrado(Numerico auto x) { return x * x; }
// Forma 4: requires inline (sin definir concept aparte)
template <typename T>
requires std::integral<T> || std::floating_point<T>
T cuadrado(T x) { return x * x; }
Las cuatro formas son equivalentes. La abbreviated template syntax (forma 3) es la más concisa pero puede resultar ambigua si los parámetros tienen tipos relacionados. Para uso general, las formas 2 y 4 son las más habituales.
requires expressions: probar operaciones específicas
Una requires expression verifica que ciertas operaciones compilan sobre el tipo, sin ejecutarlas:
template <typename T>
concept Serializable = requires(T t, std::ostream& os) {
// Comprobar que estas expresiones compilan:
{ t.serialize() } -> std::convertible_to<std::string>;
os << t; // operator<< disponible
T::version; // miembro estático
};
template <typename T>
concept Comparable = requires(T a, T b) {
{ a < b } -> std::same_as<bool>;
{ a == b } -> std::same_as<bool>;
};
Concepts de la biblioteca estándar
C++20 incluye un buen conjunto de concepts predefinidos en el header <concepts> y en <iterator>:
#include <concepts> // Tipos fundamentales std::integral<T> // int, long, char, bool, ... std::floating_point<T> // float, double, long double std::signed_integral<T> // int, long, ... std::unsigned_integral<T> // unsigned int, size_t, ... std::arithmetic<T> // integral || floating_point // Relaciones entre tipos std::same_as<T, U> // T y U son el mismo tipo std::derived_from<T, U> // T hereda de U std::convertible_to<T, U> // T convertible a U std::common_with<T, U> // T y U tienen tipo común // Invocabilidad std::invocable<F, Args...> // F se puede llamar con Args std::predicate<F, Args...> // F es un predicado booleano std::regular_invocable<F, Args...> // invocable y sin efectos secundarios // Conceptos de rangos (en <ranges>) std::ranges::range<T> std::ranges::sized_range<T> std::ranges::input_range<T> std::ranges::random_access_range<T>
Sobrecargar con concepts
Uno de los usos más prácticos: tener varias implementaciones de una función según las capacidades del tipo, sin macros ni SFINAE:
#include <concepts>
#include <iterator>
// Versión rápida para random_access iterators
template <std::random_access_iterator It>
void avanzar_n(It& it, int n) {
it += n; // O(1)
}
// Versión general para el resto
template <std::input_iterator It>
void avanzar_n(It& it, int n) {
for (int i = 0; i < n; ++i) ++it; // O(n)
}
El compilador elige automáticamente la sobrecarga más restrictiva que se satisfaga. Si el iterador es random-access, usa la primera. Si no, usa la segunda. Antes esto requería std::enable_if o if constexpr anidados.
Concepts y clases
Los concepts también se aplican a métodos de clase y a parámetros de templates no-tipo:
template <typename T>
struct Stack {
std::vector<T> datos;
void push(T v) { datos.push_back(std::move(v)); }
// Solo disponible si T es imprimible
void print() const requires requires(T t) { std::cout << t; } {
for (const auto& x : datos) std::cout << x << ' ';
std::cout << 'n';
}
};
La sustitución del SFINAE
Antes de C++20, la técnica estándar para restringir templates era SFINAE (Substitution Failure Is Not An Error), que explota el hecho de que el compilador descarta silenciosamente las instanciaciones que fallan en la deducción de tipos. La sintaxis era críptica:
// SFINAE: difícil de leer, imposible de depurar
template <typename T,
typename = std::enable_if_t<std::is_integral_v<T>>>
T doble(T x) { return x * 2; }
// Concepts: legible y con errores claros
template <std::integral T>
T doble(T x) { return x * 2; }
Los concepts no eliminan completamente el SFINAE (sigue usándose en contextos de bajo nivel), pero para el 95% de los casos donde antes usabas enable_if, un concept es la solución más clara.
Junto con los módulos (artículo siguiente de esta serie) y los rangos, los concepts son parte de lo que hace que escribir templates modernos en C++20 sea una experiencia bastante más llevadera que en C++17. Si te interesa cómo Rust enfoca el mismo problema desde otra ángulo, los traits de Rust 2024 edition tienen un papel similar al de los concepts.
Y si quieres ver los concepts en acción dentro de la misma biblioteca estándar, los algoritmos de std::ranges los usan extensivamente para distinguir entre rangos de entrada, bidireccionales y de acceso aleatorio.
Imagen: Pexels / Pixabay
