Mayor complejidad de las declaraciones de algoritmos de rango de C++20:¿vale la pena?

Mayor complejidad de las declaraciones de algoritmos de rango de C++20:¿vale la pena?

Con la adición de Rangos y Conceptos en C++20, nuestras buenas interfaces de algoritmos antiguos obtuvieron versiones "rangificadas" súper largas. Por ejemplo, copy ahora tiene 4 líneas de largo... ¡y es solo la declaración!

template <ranges::input_range R, std::weakly_incrementable O>
requires std::indirectly_copyable<ranges::iterator_t<R>, O>
constexpr ranges::copy_result<ranges::borrowed_iterator_t<R>, O>
copy(R&& r, O result);

¿Cómo descifrar una declaración tan larga? ¿Qué beneficios obtenemos en cambio? ¿Vale la pena? Vamos a averiguarlo.

Declaraciones superlargas

Aquí hay algunos algoritmos que tienen las versiones de rango en C++20. Están disponibles en el std::ranges espacio de nombres y ubicado en el <algorithm> encabezado.

Copiar:

template< ranges::input_range R, std::weakly_incrementable O >
requires std::indirectly_copyable<ranges::iterator_t<R>, O>
constexpr ranges::copy_result<ranges::borrowed_iterator_t<R>, O>
copy( R&& r, O result );

¡4 líneas!

Y aquí está la versión estándar, solo dos líneas:

template< class InputIt, class OutputIt >
constexpr OutputIt copy( InputIt first, InputIt last, OutputIt d_first );

Otro:find_if :

template<ranges::input_range R, class Proj = std::identity,
          std::indirect_unary_predicate<std::projected<ranges::iterator_t<R>, Proj>> Pred >
constexpr ranges::borrowed_iterator_t<R> find_if( R&& r, Pred pred = {}, Proj proj = {} );

Vs el "viejo":

template< class InputIt, class UnaryPredicate >
constexpr InputIt find_if( InputIt first, InputIt last, UnaryPredicate p );

Puede ver otro algoritmo en esta práctica página en Referencia de C++:Algoritmos restringidos (desde C++20) - cppreference.com y la versión estándar "antigua" en:Biblioteca de algoritmos - cppreference.com

Descifrando

Esas nuevas declaraciones pueden resultar intimidantes al principio, tratemos de descifrar esa sintaxis.

Como ejemplo, podemos tomar std::ranges::copy_if ¡que parece una "cosa de plantilla monstruosa" al principio!

template< ranges::input_range R, std::weakly_incrementable O,
          class Proj = std::identity,
          std::indirect_unary_predicate<std::projected<ranges::iterator_t<R>, Proj>> Pred >
requires std::indirectly_copyable<ranges::iterator_t<R>, O>
constexpr ranges::copy_if_result<ranges::borrowed_iterator_t<R>, O>
copy_if( R&& r, O result, Pred pred, Proj proj = {} );

A continuación puede encontrar un caso de uso simple:

#include <algorithm>
#include <iostream>
#include <iterator>
#include <ranges>
#include <vector>

int main(){
    std::vector ints { 1, 2, 3, 4, 5, 6, 7 };
    std::ranges::copy_if(ints, std::ostream_iterator<int>(std::cout, ", "),
                          [](int x) { return (x % 2) == 0; });
}

Ver la versión en vivo @Wandbox

Este ejemplo de código muestra la API de cliente súper fácil que podemos aprovechar. Simplemente pase un contenedor completo (sin necesidad de begin/end ) y la secuencia de salida.

Para descifrar la declaración, debemos observar las cuatro partes principales:

  • el template<> declaración
  • el requires cláusula
  • el tipo de retorno
  • el declarador de funciones con una lista de parámetros

Una nota adicional:ranges::copy_if en realidad no se implementa como una función... sino como un objeto de función global... o niebloid (ver en stackoveflow). Pero esa es otra historia por ahora :)

La primera parte:

La primera parte es la más larga:

template<ranges::input_range R, std::weakly_incrementable O,
          class Proj = std::identity,
          std::indirect_unary_predicate<std::projected<ranges::iterator_t<R>, Proj>> Pred>

Describe los parámetros de la plantilla de entrada:el rango de entrada R, la salida O, la proyección y también el predicado.

Esto puede parecer un poco más complicado que el antiguo std::copy_if interfaz:

template< class InputIt, class OutputIt, class UnaryPredicate>
OutputIt copy_if( InputIt first, InputIt last, OutputIt d_first,UnaryPredicate pred );

La razón principal de su complejidad es que la declaración usa Concepts, que es una característica masiva para C++20. Por ahora, podemos decir que agregan algunos significados y requisitos adicionales a los tipos de plantillas. La interfaz anterior toma casi todo (como un void* en el sentido de "plantilla"), y luego esperamos que el compilador pueda compilar el código... pero con Concepts, podemos especificar algunas reglas y así el compilador puede detectar discrepancias desde el principio.

Por ejemplo, el rango de entrada tiene que satisfacer el input_range concepto que es:

template<class T>
  concept input_range =
    ranges::range<T> && std::input_iterator<ranges::iterator_t<T>>;
	
// the range concept:
template< class T >
concept range = requires(T& t) {
  ranges::begin(t);
  ranges::end(t);
};

Tiene sentido... ¿verdad?

El rango de entrada debe tener begin() y end() y también su tipo de iterador tiene que ser input_iterator .

Entonces la salida es weakly_incrementable entonces más o menos significa que se puede incrementar con i++ , como un iterador de salida.

La segunda parte:

La siguiente parte es un parámetro de plantilla simple para la proyección, por defecto, su identidad. En definitiva gracias a las proyecciones podemos “ver” elementos obtenidos del contenedor de forma diferente. Por ejemplo, podemos iterar a través de la colección de objetos "Usuario" y extraer solo el nombre, o realizar algún cálculo adicional. Hablaremos de eso más tarde.

Y también existe esta larga especificación para el predicado:

std::indirect_unary_predicate<std::projected<ranges::iterator_t<R>, Proj>> Pred

Brevemente, la proyección puede realizar una operación de suma en el elemento de entrada y luego el resultado se inserta en el predicado, que luego decide si el elemento coincide con los criterios de copia o no.

La tercera sección:

La otra parte “requires ":

requires std::indirectly_copyable<ranges::iterator_t<R>, O>

Esta vez restringe los tipos de entrada y salida para que puedan leer valores del iterador de entrada y luego escribirlos en la secuencia de salida. Vea el concepto estándar aquí:std::indirectly_copyable - cppreference.com

El último:

Después de todas esas restricciones, podemos leer la parte más interesante:la interfaz de la función:

copy_if( R&& r, O result, Pred pred, Proj proj = {} );

¿Fácil verdad? :)

¿Qué obtenemos en su lugar?

Las nuevas versiones de algoritmos clasificados son muy grandes y, a veces, incluso es difícil encontrar el nombre de la función.

¡Es una gran cosa porque ahora podemos lamentar que C++ era súper complicado y ahora está empeorando aún más! :)

Pero:

Pero Concepts y Ranges no son solo para hacer nuestra vida más compleja… en realidad es todo lo contrario.

¿Qué obtenemos en su lugar? ¿Cuáles son las ventajas que obtenemos pagando el precio de interfaces más extendidas?

Los Rangos

Podemos simplemente llamar al algoritmo en todo el rango, no es necesario pedir comenzar/finalizar:

std::vector ints { 1, 2, 3, 4, 5, 6, 7 };
std::ranges::copy_if(ints, ...

Con la versión normal de std::copy tienes que pasar el inicio y final de la secuencia:

std::copy_if(std::begin(ints), std::end(end), ...);

Esa es una función en sí misma y los desarrolladores de C++ soñaron con ella durante décadas :)

Composibilidad

Los rangos nos permiten componer algoritmos juntos. Puede agregar filtros, vistas, transformaciones y muchas otras operaciones que devuelven un nuevo rango. Esto no es posible con los algoritmos estándar.

Por ejemplo, podemos crear una vista simple y tomar los primeros cuatro elementos de nuestro contenedor:

std::vector ints { 1, 2, 3, 4, 5, 6, 7 };
std::ranges::copy_if(ints | std::ranges::views::take(4), std::ostream_iterator<int>(std::cout, ", "),
                     [](int x) { return (x % 2) == 0; });

Ver el código en vivo @Wandbox

Proyecciones

Mencioné esto antes, pero ahora podemos ver un ejemplo simple:

#include <algorithm>
#include <iostream>
#include <iterator>
#include <ranges>
#include <vector>

struct Package {
    double weight;
    double price;
};

int main(){
    std::vector<Package> packages { 
        {100.0, 10.0}, 
        {104.0, 7.5},
        {95.0, 17.5},
        {91.0, 15.0},
        {100.1, 12.5 },
    };
    auto print = [](Package& p) { std::cout << p.weight << ": " << p.price << '\n'; };
    std::ranges::sort(packages, {}, &Package::weight);
    std::cout << "by weight: \n";
    std::ranges::for_each(packages, print);
    std::ranges::sort(packages, {}, &Package::price);
    std::cout << "by price: \n";
    std::ranges::for_each(packages, print);
}

Código en vivo @Wandbox

Los algoritmos de rango usan std::invoke llamar a la proyección dada sobre el elemento dado del rango. Gracias a este enfoque, no solo podemos pasar objetos de función, sino también solicitar un miembro de datos de una clase.

En nuestro ejemplo anterior, simplemente podemos ordenar por Package::weight o Package::price en una sola línea de código. ¡Ni siquiera es necesario pasar comparadores personalizados!

Interfaces significativas

Con Conceptos, obtenemos una interfaz más larga pero más descriptiva para los tipos de plantillas. No son sólo <typename output, typename input> pero ahora puede aplicar restricciones y transmitir esa información vital a través del código.

Mejores advertencias

Los compiladores ahora tienen una forma de verificar si el argumento de entrada para una función de plantilla coincide con el requires cláusula y conceptos en la declaración. Pueden mejorar potencialmente el lado de las advertencias y hacer que sus mensajes sean más claros.

Tiempo de compilación reducido (con suerte)

¡Está mejorando! Por un lado, los rangos son una bestia complicada, y la compilación puede hacer que el código se hinche, pero por otro lado, los conceptos pueden ayudar a los compiladores a procesar las cosas más rápido.

Resumen

En esta publicación de blog, quería presentar que si bien las nuevas declaraciones de funciones de rango y algoritmos pueden parecer muy complicadas, están aquí por una razón. No solo nos brindan mejores interfaces, con parámetros más precisos, sino que también permiten una fácil composición de algoritmos o incluso hacer proyecciones.

Tienes que aprender nuevas construcciones y sintaxis, pero vale la pena el precio.

Parece que mientras tiene declaraciones de función 2 veces más largas para esos nuevos algoritmos, su código de cliente final es varias veces más corto.

¿Qué piensas? ¿Has jugado con Ranges? ¿Cuál es tu experiencia hasta ahora?