Estándar C ++ 20: una descripción general de las nuevas características de C ++. Parte 3 "Conceptos"





El 25 de febrero, el autor del curso "C ++ Developer" en Yandex.Trabajo práctico Georgy Osipov habló sobre la nueva etapa del lenguaje C ++: el estándar C ++ 20. La conferencia ofrece una descripción general de todas las principales innovaciones del Estándar, explica cómo aplicarlas ahora y cómo pueden ser útiles.



Al preparar el seminario web, el objetivo era proporcionar una descripción general de todas las características clave de C ++ 20. Por lo tanto, el seminario web resultó ser rico y duró casi 2,5 horas. Para su comodidad, hemos dividido el texto en seis partes:



  1. Módulos y una breve historia de C ++ .
  2. Operación "nave espacial" .
  3. Conceptos.
  4. Rangos.
  5. Corutinas.
  6. Otras funciones básicas y estándar de la biblioteca. Conclusión.


Esta es la tercera parte, que cubre los conceptos y las limitaciones del C ++ moderno.



Conceptos







Motivación



La programación genérica es una ventaja clave de C ++. No conozco todos los idiomas, pero nunca había visto nada parecido a este nivel.



Sin embargo, la programación genérica en C ++ tiene una gran desventaja: los errores que ocurren son dolorosos. Considere un programa simple que ordena un vector. Eche un vistazo al código y dígame dónde está el error:



#include <vector>
#include <algorithm>
struct X {
    int a;
};
int main() {
    std::vector<X> v = { {10}, {9}, {11} };
    //  
    std::sort(v.begin(), v.end());
}
      
      





Definí una estructura X



con un campo int



, llené un vector con objetos de esa estructura y estoy tratando de ordenarlo.



Espero que leas el ejemplo y encuentres el error. Anunciaré la respuesta: el compilador cree que el error está en ... la biblioteca estándar. La salida de diagnóstico tiene aproximadamente 60 líneas de largo e indica un error en algún lugar dentro del archivo de ayuda de xutility. Es casi imposible leer y comprender los diagnósticos, pero los programadores de C ++ lo hacen; después de todo, aún necesita usar plantillas.







El compilador muestra que el error está en la biblioteca estándar, pero esto no significa que deba escribir inmediatamente al Comité de Normalización. De hecho, el error todavía está en nuestro programa. Es solo que el compilador no es lo suficientemente inteligente como para resolverlo y se encuentra con un error cuando ingresa a la biblioteca estándar. Desentrañar este diagnóstico conduce a un error. Pero esto:



  • Complicado,
  • no siempre es posible en principio.


Formulemos el primer problema de la programación genérica en C ++: los errores al usar plantillas son completamente ilegibles y no se diagnostican donde se hicieron, sino en la plantilla.



Surge otro problema si existe la necesidad de utilizar diferentes implementaciones de una función dependiendo de las propiedades del tipo de argumento. Por ejemplo, quiero escribir una función que verifique que dos números estén lo suficientemente cerca uno del otro. Para enteros basta con comprobar que los números son iguales, para números de coma flotante basta comprobar que la diferencia es menor que algunos ε.



El problema se puede resolver con el truco SFINAE escribiendo dos funciones. Usos de pirateo std::enable_if



... Esta es una plantilla especial en la biblioteca estándar que contiene un error si no se cumple la condición. Al crear una instancia de una plantilla, el compilador descarta las declaraciones con un error:



#include <type_traits>

template <class T>
T Abs(T x) {
    return x >= 0 ? x : -x;
}

//      
template<class T>
std::enable_if_t<std::is_floating_point_v<T>, bool>
AreClose(T a, T b) {
    return Abs(a - b) < static_cast<T>(0.000001);
}

//    
template<class T>
std::enable_if_t<!std::is_floating_point_v<T>, bool> 
AreClose(T a, T b) {
    return a == b;
}
      
      





En C ++ 17, dicho programa se puede simplificar usando if constexpr



, aunque esto no funcionará en todos los casos.



U otro ejemplo: quiero escribir una función Print



que imprima cualquier cosa. Si se le pasó un contenedor, imprimirá todos los elementos, si no el contenedor, imprimirá lo que se pasó. Voy a tener que definirlo para todos los contenedores: vector



, list



, set



y otros. Esto es inconveniente y no universal.



template<class T>
void Print(std::ostream& out, const std::vector<T>& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

//      map, set, list, 
// deque, array…

template<class T>
void Print(std::ostream& out, const T& v) {
    out << v;
}
      
      





SFINAE ya no ayudará aquí. Más bien, será útil si lo intenta, pero tendrá que intentarlo mucho y el código resultará monstruoso.



El segundo problema con la programación genérica es que es difícil escribir diferentes implementaciones de la misma función de plantilla para diferentes categorías de tipos.



Ambos problemas se pueden resolver fácilmente si agrega solo una característica al idioma: imponer restricciones a los parámetros de la plantilla . Por ejemplo, requiera que el parámetro con plantilla sea un contenedor u objeto que admita comparaciones. Este es el concepto.



Lo que otros tienen



Veamos cómo van las cosas en otros idiomas. El único que conozco que tiene algo similar es Haskell.



class Eq a where
	(==) :: a -> a -> Bool
	(/=) :: a -> a -> Bool
      
      





Este es un ejemplo de una clase de tipo que requiere soporte para los operadores "iguales" y "no iguales" que emiten Bool



. En C ++, se haría lo mismo así:



template<typename T>
concept Eq =
    requires(T a, T b) {
        { a == b } -> std::convertible_to<bool>;
        { a != b } -> std::convertible_to<bool>;
    };
      
      





Si aún no está familiarizado con los conceptos, será difícil entender lo que está escrito. Te lo explicaré todo ahora.



En Haskell, se requieren estas restricciones. Si no dice que habrá una operación ==



, no podrá usarla. En C ++, las restricciones no son estrictas. Incluso si no especifica una operación en el concepto, aún se puede usar; después de todo, antes no había restricciones en absoluto y los nuevos estándares se esfuerzan por no violar la compatibilidad con los anteriores.



Ejemplo



Complementemos el código del programa en el que recientemente buscaste un error:



#include <vector>
#include <algorithm>
#include <concepts>

template<class T>
concept IterToComparable = 
    requires(T a, T b) {
        {*a < *b} -> std::convertible_to<bool>;
    };
    
//    IterToComparable   class
template<IterToComparable InputIt>
void SortDefaultComparator(InputIt begin, InputIt end) {
    std::sort(begin, end);
}

struct X {
    int a;
};

int main() {
    std::vector<X> v = { {10}, {9}, {11} };
    SortDefaultComparator(v.begin(), v.end());
}
      
      





Aquí hemos creado un concepto IterToComparable



. Muestra que el tipo T



es un iterador y apunta a valores que se pueden comparar. El resultado de la comparación es algo convertible bool



, por ejemplo, en sí mismo bool



. Se proporcionará una explicación detallada un poco más adelante, por ahora no es necesario profundizar en este código.



Por cierto, las restricciones son débiles. No dice que un tipo deba satisfacer todas las propiedades de los iteradores: por ejemplo, no es necesario incrementarlo. Este es un ejemplo simple para demostrar las posibilidades.



El concepto se usó en lugar de una palabra class



o typename



en la construcción de c template



. Solía ​​serlo template<class InputIt>



, pero ahora la palabra class



reemplazado con el nombre del concepto. Por tanto, el parámetro InputIt



debe satisfacer la restricción.



Ahora, cuando intentemos compilar este programa, el error no aparecerá en la biblioteca estándar, sino como debería estar: en main



. Y el error es comprensible, ya que contiene toda la información necesaria:



  • ¿Qué sucedió? Llamada a función con restricción no cumplida.
  • ¿Qué restricción no se satisface? IterToComparable<InputIt>



  • ¿Por qué? La expresión ((* a) < (* b))



    no es válida.




La salida del compilador es legible y ocupa 16 líneas en lugar de 60.



main.cpp: In function 'int main()':
main.cpp:24:45: error: **use of function** 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]' **with unsatisfied constraints**
   24 |     SortDefaultComparator(v.begin(), v.end());
      |                                             ^
main.cpp:12:6: note: declared here
   12 | void SortDefaultComparator(InputIt begin, InputIt end) {
      |      ^~~~~~~~~~~~~~~~~~~~~
main.cpp:12:6: note: constraints not satisfied
main.cpp: In instantiation of 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]':
main.cpp:24:45:   required from here
main.cpp:6:9:   **required for the satisfaction of 'IterToComparable<InputIt>'** [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >]
main.cpp:7:5:   in requirements with 'T a', 'T b' [with T = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >]
main.cpp:8:13: note: the required **expression '((* a) < (* b))' is invalid**, because
    8 |         {*a < *b} -> std::convertible_to<bool>;
      |          ~~~^~~~
main.cpp:8:13: error: no match for 'operator<' (operand types are 'X' and 'X')
      
      





Agreguemos la operación de comparación que falta a la estructura y el programa se compilará sin errores; el concepto se cumple:



struct X {
    auto operator<=>(const X&) const = default;
    int a;
};
      
      





Del mismo modo, puede mejorar el segundo ejemplo, p enable_if



. Esta plantilla ya no es necesaria. En su lugar, usamos el concepto estándar is_floating_point_v<T>



. Obtenemos dos funciones: una para números de punto flotante, la otra para otros objetos:



#include <type_traits>

template <class T>
T Abs(T x) {
    return x >= 0 ? x : -x;
}

//      
template<class T>
requires(std::is_floating_point_v<T>)
bool AreClose(T a, T b) {
    return Abs(a - b) < static_cast<T>(0.000001);
}

//    
template<class T>
bool AreClose(T a, T b) {
    return a == b;
}
      
      





También modificamos la función de impresión. Si llamamos a.begin()



y a.end()



decimos, asumimos ese a



contenedor.



#include <iostream>
#include <vector>

template<class T>
concept HasBeginEnd = 
    requires(T a) {
        a.begin();
        a.end();
    };

template<HasBeginEnd T>
void Print(std::ostream& out, const T& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

template<class T>
void Print(std::ostream& out, const T& v) {
    out << v;
}
      
      





Nuevamente, este no es un ejemplo ideal, ya que el contenedor no es solo algo con begin



y end



, se le imponen muchos más requisitos. Pero ya no está mal.



Es mejor utilizar un concepto prefabricado como is_floating_point_v



en el ejemplo anterior. Para un análogo de contenedores, la biblioteca estándar también tiene un concepto - std::ranges::input_range



. Pero esa es una historia completamente diferente.



Teoría



Es hora de entender cuál es el concepto. Realmente no hay nada complicado aquí:



Concepto es el nombre de una restricción.



Lo hemos reducido a otro concepto, cuya definición ya tiene sentido, pero puede parecer extraño: la



restricción es una expresión repetitiva.



En términos generales, las condiciones anteriores "ser un iterador" o "ser un número de coma flotante"; estas son las restricciones. Toda la esencia de la innovación radica precisamente en las limitaciones, y el concepto es solo una forma de referirse a ellas.



La limitación más simple es esta true



. Cualquier tipo le conviene.



template<class T> concept C1 = true;
      
      





Las operaciones booleanas y las combinaciones de otras restricciones están disponibles para las restricciones:



template <class T>
concept Integral = std::is_integral<T>::value;

template <class T>
concept SignedIntegral = Integral<T> &&
                         std::is_signed<T>::value;
template <class T>
concept UnsignedIntegral = Integral<T> &&
                           !SignedIntegral<T>;
      
      





Puede utilizar expresiones en restricciones e incluso funciones de llamada. Pero las funciones deben ser constexpr, se calculan en tiempo de compilación:



template<typename T>
constexpr bool get_value() { return T::value; }
 
template<typename T>
    requires (sizeof(T) > 1 && get_value<T>())
void f(T); // #1
 
void f(int); // #2
 
void g() {
    f('A'); //  #2.
}
      
      





Y la lista de posibilidades no termina ahí.



Hay una gran característica para las restricciones: verificar la exactitud de la expresión, que se compila sin errores. Mira la limitación Addable



. Está escrito entre paréntesis a + b



. Las condiciones de restricción se cumplen cuando los valores a



y b



tipos T



permiten tal registro, es decir, T



tiene una determinada operación de adición:



template<class T>
concept Addable =
requires (T a, T b) {
    a + b;
};
      
      





Un ejemplo más complejo es llamar a funciones swap



y forward



. La restricción se ejecutará cuando este código se compile sin errores:



template<class T, class U = T>
concept Swappable = requires(T&& t, U&& u) {
    swap(std::forward<T>(t), std::forward<U>(u));
    swap(std::forward<U>(u), std::forward<T>(t));
};
      
      





Otro tipo de restricción es la validación de tipos:



template<class T> using Ref = T&;
template<class T> concept C =
requires {
    typename T::inner; 
    typename S<T>;     
    typename Ref<T>;   
};
      
      





Una restricción puede requerir no solo la corrección de la expresión, sino también que el tipo de su valor corresponda a algo. Aquí escribimos:



  • expresión entre llaves,
  • ->,



  • otra limitación.


template<class T> concept C1 =
requires(T x) {
    {x + 1} -> std::same_as<int>;
};
      
      





La limitación en este caso: same_as<int>





es decir, el tipo de expresión x + 1



debe ser exactamente int



.



Tenga en cuenta que la flecha va seguida de la restricción, no del tipo en sí. Mira otro ejemplo del concepto:



template<class T> concept C2 =
requires(T x) {
    {*x} -> std::convertible_to<typename T::inner>;
    {x * 1} -> std::convertible_to<T>;
};
      
      





Tiene dos limitaciones. El primero indica que:



  • la expresión es *x



    correcta;
  • el tipo es T::inner



    correcto;
  • tipo se *x



    convierte aT::inner.





Hay tres requisitos en una línea. El segundo indica que:



  • la expresión es x * 1



    sintácticamente correcta;
  • su resultado se convierte en T



    .


Cualquier restricción puede formarse utilizando los métodos anteriores. Son muy divertidos y agradables, pero rápidamente obtendría suficientes y olvidaría si no pudiera usarlos. Y puede usar restricciones y conceptos para cualquier cosa que admita plantillas. Por supuesto, los usos principales son funciones y clases.



Entonces, hemos descubierto cómo escribir restricciones , ahora le diré dónde puede escribirlas .



Una restricción de función se puede escribir en tres lugares diferentes:



//   class  typename   .
//   .
template<Incrementable T>
void f(T arg);

//    requires.       
//     .
//    .
template<class T>
requires Incrementable<T>
void f(T arg);

template<class T>
void f(T arg) requires Incrementable<T>;
      
      





Y hay una cuarta forma, que parece bastante mágica:



void f(Incrementable auto arg);
      
      





Aquí se utiliza una plantilla implícita. Hasta C ++ 20, solo estaban disponibles en lambdas. Ahora puede ser utilizado auto



en cualquier firma de función: void f(auto arg)



. Además, auto



se permite un nombre de concepto antes de esto , como en el ejemplo. Por cierto, las plantillas explícitas ahora están disponibles en lambdas, pero más sobre eso más adelante.



Una diferencia importante: cuando escribimos requires



, podemos escribir cualquier restricción y, en otros casos, solo el nombre del concepto.



Hay menos posibilidades para una clase, solo de dos formas. Pero esto es suficiente:



template<Incrementable T>
class X {};
template<class T>
requires Incrementable<T>
class Y {};
      
      





Anton Polukhin, quien ayudó con la preparación de este artículo, notó que la palabra requires



se puede usar no solo al declarar funciones, clases y conceptos, sino también en el cuerpo de una función o método. Por ejemplo, es útil si está escribiendo una función que llena un contenedor de un tipo previamente desconocido:



template<class T> 
void ReadAndFill(T& container, int size) { 
    if constexpr (requires {container.reserve(size); }) { 
        container.reserve(size); 
    }

    //   
}
      
      





Esta función funcionará igualmente bien con ambos vector



, y con list



, y para el primero, se llamará al método necesario en su caso reserve



.



Útil requires



para static_assert



. De esta manera, puede verificar el cumplimiento no solo de las condiciones ordinarias, sino también la corrección del código arbitrario, la presencia de métodos y operaciones en tipos.



Curiosamente, un concepto puede tener varios parámetros de plantilla. Al usar el concepto, debe especificar todo excepto uno, el que estamos verificando para la restricción.



template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
 
template<Derived<Other> X>
void f(X arg);
      
      





El concepto tiene Derived



dos parámetros de plantilla. En la declaración, f



indiqué uno de ellos y el segundo: la clase X



, que está marcada. Se preguntó a la audiencia qué parámetro indiqué: T



o U



; funcionó Derived<Other, X>



o Derived<X, Other>



?



La respuesta no es obvia: lo es Derived<X, Other>



. Al especificar un parámetro Other



, especificamos un segundo parámetro de plantilla. Los resultados de la votación divergieron:



  • respuestas correctas - 8 (61,54%);
  • respuestas incorrectas - 5 (38,46%).


Al especificar los parámetros del concepto, debe especificar todo excepto el primero, y se verificará el primero. Durante mucho tiempo pensé por qué el Comité tomó esa decisión y le sugiero que usted también lo piense. Escribe tus ideas en los comentarios.



Entonces, les dije cómo definir nuevos conceptos, pero esto no siempre es necesario, ya hay muchos de ellos en la biblioteca estándar. Esta diapositiva muestra los conceptos que se encuentran en el archivo de encabezado <concepts>.







Eso no es todo: existen conceptos para probar diferentes tipos de iteradores en <iterator>, <ranges> y otras bibliotecas.







Estado







Los "conceptos" están en todas partes, pero aún no completamente en Visual Studio:



  • GCC. Bien soportado desde la versión 10;
  • Sonido metálico. Soporte completo en la versión 10;
  • Estudio visual. Compatible con VS 2019, pero no completamente implementado requiere.


Conclusión



Durante la transmisión, le preguntamos a la audiencia si les gustó esta función. Resultados de la encuesta:



  • Super función - 50 (92,59%)
  • Entonces, característica - 0 (0.00%)
  • Poco claro - 4 (7,41%)


La abrumadora mayoría de los que votaron apreciaron los conceptos. También creo que esta es una característica interesante. ¡Gracias al Comité!



Los lectores de Habr, así como los oyentes de los seminarios web, tendrán la oportunidad de evaluar las innovaciones.



All Articles