Expresiones requires: el núcleo de los Concepts en C++20

La expression requires es el átomo fundamental de la metaprogramación de tipos en C++20. A diferencia de lo que su nombre sugiere, no es una sentencia que ejecute código, sino una expresión que evalúa si un conjunto de operaciones es sintácticamente válida y semánticamente aceptable para un tipo dado, devolviendo un bool en tiempo de compilación. Mientras que en C++17 dependíamos de la tortuosa técnica de SFINAE (Substitution Failure Is Not An Error) mediante std::enable_if, las expresiones requires permiten realizar introspección sobre la interfaz de un tipo de forma directa y legible.

El compilador implementa esto mediante un mecanismo de validación de la gramática: cuando evalúa una expresión requires, el compilador “simula” la llamada a los métodos o la aplicación de operadores. Si la expresión es válida para el tipo, la expresión devuelve true; si no, devuelve false sin necesidad de disparar un error de compilación inmediato (lo que permite que el motor de resolución de sobrecarga continúe buscando otras candidatas).

Debes usar expresiones requires simples cuando solo te interese la existencia de una sintaxis (por ejemplo, si un tipo tiene un método foo()). Debes escalar a expresiones requires compuestas cuando necesites validar la semántica: si el método devuelve un tipo específico, si el resultado es convertible a otro, o si la operación es noexcept. El uso de estas expresiones es el corazón de la definición de Concepts [C++20].

Si intentas usar una expresión requires dentro de una cláusula de restricción de forma incorrecta, o si confías en que una expresión será válida solo por su sintaxis sin verificar su contrato de excepciones (noexcept), podrías encontrarte con fallos de compilación crípticos en el punto de uso, lejos de donde definiste el error, o peor aún, errores de diseño donde tu código es sintácticamente correcto pero semánticamente inválido para el contrato que esperabas.

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

// Concepto simple: Solo verifica que el tipo tenga un método .size()
template<typename T>
concept HasSize = requires(T t) {
    t.size();
};

// Concepto compuesto: Verifica sintaxis, tipo de retorno y especificación de excepción
template<typename T>
concept FastSizeable = requires(T t) {
    // La expresión debe ser válida, devolver algo convertible a size_t 
    // y la llamada no debe poder lanzar excepciones.
    { t.size() } noexcept -> std::convertible_to<std::size_t>;
};

// Concepto anidado: Combinación de otros conceptos
template<typename T>
concept Container = HasSize<T> && requires(T t) {
    { t.begin() } -> std::input_iterator;
    { t.end() } -> std::input_iterator;
};

// Uso de 'requires requires' (anti-patrón si se usa sin nombrar)
// En su lugar, definimos un concepto nombrado para mayor claridad.
template<typename T>
concept Printable = requires(T t) {
    std::cout << t; 
};

// Ejemplo de uso en una clase con restricciones (Constraints on classes)
template<typename T>
requires Container<T> && FastSizeable<T>
class DataBuffer {
private:
    T data;
public:
    explicit DataBuffer(T d) : data(std::move(d)) {}
    const T& get() const { return data; }
};

// Función con cláusula 'requires' simple
void print_info(Printable auto const& item) {
    std::cout << "Elemento: " << item << "\n";
}

// Función con expresión 'requires' anidada directamente (no recomendado para lógica compleja)
template<typename T>
void process_complex(T&& t) {
    if constexpr (requires(T a) { { a.size() } -> std::same_as<std::size_t>; }) {
        std::cout << "El tamaño es exactamente std::size_t\n";
    }
}

int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    std::string str = "Hola C++20";

    // Validado por HasSize y Container
    DataBuffer buffer(vec);
    std::cout << "Buffer size: " << buffer.get().size() << "\n";

    // Validado por Printable
    print_info(str);
    print_info(vec[0]); // Error si intentamos pasar un entero sin sobrecarga, 
                        // pero funciona si el tipo tiene operator<<

    // Demostración de validación de tipo de retorno
    process_complex(vec); 

    return 0;
}

Desglose del concepto

En el ejemplo, hemos visto tres niveles de complejidad:

  1. HasSize (Simple): La expresión t.size(); dentro de requires(T t) comprueba si el compilador puede derivar una llamada válida. No nos importa qué devuelva size(), solo que exista.
  2. FastSizeable (Compuesto): Aquí la sintaxis { t.size() } noexcept -> std::convertible_to<std::size_t>; es clave. El uso de llaves {} encapsula la expresión para que podamos aplicar operadores sobre su resultado. noexcept verifica que la expresión sea marcada como no-lanzadora. Si t.size() lanzara una excepción, el concepto resultaría false. std::convertible_to es un concepto de la biblioteca estándar que valida que el tipo devuelto sea compatible con std::size_t mediante una conversión implícita.
  3. Container (Anidado/Combinado): En Container, combinamos la validación de existencia de size() con la validación de que begin() y end() devuelvan tipos que satisfagan el concepto std::input_iterator [C++20].
  4. DataBuffer (Constraints en clases): Hemos aplicado la restricción directamente en la definición de la clase. Esto asegura que no se pueda instanciar DataBuffer<int>, porque int no cumple Container. Esto es preferible a usar static_assert dentro del constructor, ya que permite una mejor selección de sobrecargas en plantillas.
  5. process_complex (Nested requires): Aunque el código muestra un if constexpr (requires(...)), esto es una expresión requires actuando como un valor booleano en tiempo de compilación. Se utiliza para “feature detection” (detección de características) sin necesidad de definir un concepto formal.

El error frecuente

Un error muy común al trabajar con expresiones compuestas es olvidar que la validación de noexcept es extremadamente estricta.

template<typename T>
concept StrictNoexcept = requires(T t) {
    { t.do_something() } noexcept;
};

struct Safe {
    void do_something() noexcept {}
};

struct Unsafe {
    void do_something() {} // No tiene noexcept
};

// Safe funciona, pero Unsafe hace que StrictNoexcept sea false.

Si tu código depende de una optimización basada en que una función no lanza excepciones (por ejemplo, para usar std::is_nothrow_move_constructible), pero tu concepto requires no verifica explícitamente la cláusula noexcept, podrías estar permitiendo tipos que rompen tus garantías de seguridad de excepciones, lo cual es un bug de diseño difícil de rastrear. Además, recuerda que el uso de requires requires (una cláusula requires seguida de una expresión requires) es sintácticamente válido pero es un anti-patrón de legibilidad; siempre es mejor extraer la lógica a un concept nombrado.

Para compilar el ejemplo:
g++ -std=c++20 -Wall -Wextra -Wpedantic -o example example.cpp

56

Dejar un comentario

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

Scroll al inicio