Curry y aplicación parcial en C ++ 14

En este artículo, le contaré acerca de una de las opciones de curry y la aplicación parcial de las funciones en C ++, que es mi favorito personal. También voy a mostrar mi propia implementación piloto de esta cosa y explicar el punto de curry sin una fórmula matemática compleja, haciéndolo realmente simple para usted. También veremos qué hay debajo del capó de la biblioteca kari.hpp que usaremos para las funciones de curry. De todos modos, hay muchas cosas fascinantes adentro, ¡así que bienvenido!


Curry


Entonces, ¿qué es curry? Supongo que es una de esas palabras que escuchas de los programadores de Haskell todo el tiempo (después de la mónada , por supuesto). Esencialmente, la definición del término es bastante simple, por lo que aquellos lectores que ya hayan escrito en lenguajes de tipo ML o Haskell , o que sepan lo que significa en otro lugar, pueden saltear esta sección.


Curry : es la técnica de transformar una función que toma N argumentos en una función, que toma un solo argumento y devuelve la función del siguiente argumento, y continúa y uno hasta que devolvamos la función del último argumento, que representará El resultado general. Creo que ayuda si te muestro ejemplos:


int sum2(int lhs, int rhs) { return lhs + rhs; } 

Aquí tenemos una función de suma binaria. ¿Y si queremos convertirlo en una función de variable única? En realidad es muy simple:


 auto curried_sum2(int lhs) { return [=](int rhs) { return sum2(lhs, rhs); }; } 

No, que hicimos? Tomamos un valor basado en un único argumento llamado lambda que a su vez toma el segundo argumento y realiza la suma en sí. Como resultado, podemos aplicar la función curry curried_sum2 a nuestros argumentos uno por uno:


 // output: 42 std::cout << sum2(40, 2) << std::endl; std::cout << curried_sum2(40)(2) << std::endl; 

Y ese es realmente el objetivo de la operación de curry. Por supuesto, es posible hacerlo con funciones de cualquier arity : funcionará de la misma manera. Devolveremos una función currificada de argumentos N-1 cada vez que tomemos el valor de otro argumento:


 auto sum3(int v1, int v2, int v3) { return v1 + v2 + v3; } auto curried_sum3(int v1) { return [=](int v2){ return [=](int v3){ return sum3(v1, v2, v3); }; }; } // output: 42 std::cout << sum3(38, 3, 1) << std::endl; std::cout << curried_sum3(38)(3)(1) << std::endl; 

Aplicación parcial


Aplicación parcial : es una forma de invocar funciones de N argumentos cuando toman solo una parte de los argumentos y devuelven otra función de los argumentos restantes.


A este respecto, debe tenerse en cuenta que en lenguajes como Haskell este proceso funciona automáticamente, a espaldas de un programador. Lo que estamos tratando de hacer aquí es realizarlo explícitamente, sum3 , llamar a nuestra función sum3 esta manera: sum3(38,3)(1) o tal vez así: sum3(38)(3,1) . Además de eso, si una función devuelve otra función que se ha cursado, también se puede llamar usando la lista de argumentos de la primera función. Veamos el ejemplo:


 int boo(int v1, int v2) { return v1 + v2; } auto foo(int v1, int v2) { return kari::curry(boo, v1 + v2); } // output: 42 std::cout << kari::curry(foo)(38,3,1) << std::endl; std::cout << kari::curry(foo)(38,3)(1) << std::endl; std::cout << kari::curry(foo)(38)(3,1) << std::endl; 

De hecho, nos hemos adelantado un poco aquí, mostrando un ejemplo del uso de kari.hpp , así que sí, lo hace.


Establecer los objetivos


Antes de escribir algo, es necesario (o deseable) comprender lo que queremos tener al final. Y queremos tener la oportunidad de curry y aplicar parcialmente cualquier función que pueda llamarse en C ++. Que son:


  • lambdas (incluidas las genéricas)
  • objetos de función (functors)
  • funciones de cualquier arity (incluidas plantillas)
  • funciones variadas
  • métodos de una clase

Las funciones variables se pueden cursar especificando un número exacto de argumentos que queremos curry. La interacción estándar con std :: bind y sus resultados también son deseables. Y, por supuesto, necesitamos una oportunidad para aplicar funciones de variables múltiples y llamar a funciones anidadas para que parezca que hemos estado trabajando con una función currificada.


Y tampoco debemos olvidarnos del rendimiento. Necesitamos minimizar los costos computacionales de los envoltorios, la transferencia de argumentos y su almacenamiento. Significa que tenemos que movernos en lugar de copiar, almacenar solo lo que realmente necesitamos y devolver (con mayor eliminación) los datos lo más rápido posible.


¡Autor, has estado intentando inventar std::bind one de nuevo!


Si y no. std::bind es sin duda una herramienta poderosa y probada, y no tengo la intención de escribir su asesino o alternativa. Sí, se puede usar para cursar y para aplicaciones parciales explícitas (especificando exactamente qué argumentos estamos aplicando, y dónde y cuántos). Pero seguro que no es el enfoque más conveniente, sin mencionar que no siempre es aplicable, ya que tenemos que conocer la aridad de la función y escribir enlaces específicos dependiendo de eso. Por ejemplo:


 int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } // std::bind auto c0 = std::bind(foo, _1, _2, _3, _4); auto c1 = std::bind(c0, 15, _1, _2, _3); auto c2 = std::bind(c1, 20, 2, _1); auto rr = c2(5); std::cout << rr << std::endl; // output: 42 // kari.hpp auto c0 = kari::curry(foo); auto c1 = c0(15); auto c2 = c1(20, 2); auto rr = c2(5); std::cout << rr << std::endl; // output: 42 

API


 namespace kari { template < typename F, typename... Args > constexpr decltype(auto) curry(F&& f, Args&&... args) const; template < typename F, typename... Args > constexpr decltype(auto) curryV(F&& f, Args&&... args) const; template < std::size_t N, typename F, typename... Args > constexpr decltype(auto) curryN(F&& f, Args&&... args) const; template < typename F > struct is_curried; template < typename F > constexpr bool is_curried_v = is_curried<F>::value; template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename... As > constexpr decltype(auto) operator()(As&&... as) const; }; } 



kari::curry(F&& f, Args&&... args)


Devuelve un objeto de función del tipo curry_t (una función curry) con argumentos opcionales aplicados args o con el resultado de la aplicación de los argumentos a la función dada f (es la función es nulary, o los argumentos transferidos fueron suficientes para llamarlo).


Si el parámetro f contiene la función que ya se ha cursado, devuelve su copia con los argumentos aplicados.




kari::curryV(F&& f, Args&&... args)


Permite curry funciones con número variable de argumentos. Después de eso, estas funciones se pueden llamar usando el operador () sin argumentos. Por ejemplo:


 auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); c2(); // output: 37 + 5 = 42 

Si el parámetro f contiene una función que ya se ha cursado, devuelve su copia con un tipo alterado de aplicación para un número variable de argumentos con los argumentos aplicados.




kari::curryN(F&& f, Args&&... args)


Permite curry funciones con un número variable de argumentos al especificar un número exacto N de argumentos que queremos aplicar (excepto los dados en argumentos). Por ejemplo:


 char buffer[256] = {'\0'}; auto c = kari::curryN<3>(std::snprintf, buffer, 256, "%d + %d = %d"); c(37, 5, 42); std::cout << buffer << std::endl; // output: 37 + 5 = 42 

Si el parámetro f contiene una función que ya se ha cursado, devuelve su copia con el tipo de aplicación alterado para N argumentos con los argumentos aplicados.




kari::is_curried<F>, kari::is_curried_v<F>


Algunas estructuras auxiliares para verificar si una función ya se ha cursado. Por ejemplo:


 const auto l = [](int v1, int v2){ return v1 + v2; }; const auto c = curry(l); // output: is `l` curried? no std::cout << "is `l` curried? " << (is_curried<decltype(l)>::value ? "yes" : "no") << std::endl; // output: is `c` curried? yes std::cout << "is `c` curried? " << (is_curried_v<decltype(c)> ? "yes" : "no") << std::endl; 



kari::curry_t::operator()(As&&... as)


El operador permite una aplicación total o parcial de una función curry. Devuelve la función currificada de los argumentos restantes de la función inicial F , o el valor de esta función obtenida por su aplicación en la acumulación de argumentos antiguos y nuevos argumentos as . Por ejemplo:


 int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } auto c0 = kari::curry(foo); auto c1 = c0(15, 20); // partial application auto rr = c1(2, 5); // function call - foo(15,20,2,5) std::cout << rr << std::endl; // output: 42 

Si llama a una función curry sin argumentos usando curryV o curryN , se llamará si hay suficientes argumentos. De lo contrario, devolverá una función parcialmente aplicada. Por ejemplo:


 auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); // force call variadic function std::printf c2(); // output: 37 + 5 = 42 

Detalles de implementación


Al darle detalles de la implementación, voy a usar C ++ 17 para mantener el texto del artículo corto y evitar explicaciones innecesarias y SFINAE amontonados, así como ejemplos de implementaciones que tuve que agregar dentro de C ++ 14 estándar. Todo esto lo puede encontrar en el repositorio del proyecto, donde también puede agregarlo a sus favoritos :)




make_curry(F&& f, std::tuple<Args...>&& args)


Una función auxiliar que crea un objeto de función curry_t o aplica la función dada f a los argumentos args .


 template < std::size_t N, typename F, typename... Args > constexpr auto make_curry(F&& f, std::tuple<Args...>&& args) { if constexpr ( N == 0 && std::is_invocable_v<F, Args...> ) { return std::apply(std::forward<F>(f), std::move(args)); } else { return curry_t< N, std::decay_t<F>, Args... >(std::forward<F>(f), std::move(args)); } } template < std::size_t N, typename F > constexpr decltype(auto) make_curry(F&& f) { return make_curry<N>(std::forward<F>(f), std::make_tuple()); } 

Ahora, hay dos cosas interesantes sobre esta función:


  • lo aplicamos a los argumentos solo si es invocable para estos argumentos y el contador de aplicaciones N está en cero
  • si la función no es invocable, consideramos esta llamada como una aplicación parcial y creamos un objeto de función curry_t contiene la función y los argumentos



struct curry_t


El objeto de función supuestamente almacena la acumulación de argumentos y la función que llamaremos al aplicarlo al final. Este objeto es lo que vamos a llamar y aplicar parcialmente.


 template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename U > constexpr curry_t(U&& u, std::tuple<Args...>&& args) : f_(std::forward<U>(u)) , args_(std::move(args)) {} private: F f_; std::tuple<Args...> args_; }; 

Hay varias razones por las que almacenamos la acumulación de argumentos args_ en std :: tuple :


1) las situaciones con std :: ref se manejan automáticamente para almacenar referencias cuando sea necesario, de forma predeterminada en función del valor
2) aplicación conveniente de una función de acuerdo con sus argumentos ( std :: apply )
3) está listo para usar, por lo que no tiene que escribirlo desde cero :)


También hemos almacenado el objeto que llamamos y la función f_ por su valor, y tenga cuidado al elegir el tipo al crear uno (voy a expandir este tema a continuación), o moverlo o copiarlo usando una referencia universal en El constructor.


Un parámetro de plantilla N actúa como un contador de aplicaciones para funciones variadas.




curry_t::operator()(const As&...)


Y, por supuesto, lo que hace que todo funcione: el operador que llama al objeto de función.


 template < std::size_t N, typename F, typename... Args > struct curry_t { // 1 constexpr decltype(auto) operator()() && { return detail::make_curry<0>( std::move(f_), std::move(args_)); } // 2 template < typename A > constexpr decltype(auto) operator()(A&& a) && { return detail::make_curry<(N > 0 ? N - 1 : 0)>( std::move(f_), std::tuple_cat( std::move(args_), std::make_tuple(std::forward<A>(a)))); } // 3 template < typename A, typename... As > constexpr decltype(auto) operator()(A&& a, As&&... as) && { return std::move(*this)(std::forward<A>(a))(std::forward<As>(as)...); } // 4 template < typename... As > constexpr decltype(auto) operator()(As&&... as) const & { auto self_copy = *this; return std::move(self_copy)(std::forward<As>(as)...); } } 

El operador que realiza la llamada tiene cuatro funciones sobrecargadas.


  1. Una función sin parámetros que permite comenzar a aplicar la función variadic (creada por curryV o curryN ). Aquí disminuimos el contador de la aplicación a cero, dejando en claro que la función está lista para ser aplicada, y luego le damos todo lo necesario para que la función make_curry .


  2. Una función de un solo argumento que disminuye el contador de la aplicación en 1 (si no está en cero) y coloca nuestro nuevo argumento a en la cartera de argumentos args_ y transfiere todo esto a make_curry .


  3. Una función variada que en realidad es un truco para la aplicación parcial de varios argumentos. Lo que hace es aplicarlos recursivamente, uno por uno. Ahora, hay dos razones por las que no se pueden aplicar de una vez:


    • el contador de la aplicación puede llegar a cero antes de que no queden argumentos
    • la función f_ se puede llamar antes y devolver otra función currificada, por lo que todos los siguientes argumentos estarán destinados a ella

  4. La última función actúa como un puente entre llamar a curry_t usando curry_t y llamar a funciones usando rvalue .



Las etiquetas de las funciones ref-calificadas hacen que todo el proceso sea casi mágico. Para resumir , con su ayuda llegamos a saber que se llamó a un objeto utilizando la referencia rvalue y podemos simplemente mover los argumentos en lugar de copiarlos en la función de llamada final make_curry . De lo contrario, tendríamos que copiar los argumentos para poder tener la oportunidad de volver a llamar a esta función, asegurándonos de que los argumentos siguen ahí.


Bonos


Antes de proceder a la conclusión, me gustaría mostrarle un par de ejemplos del azúcar sintáctico que tienen en kari.hpp que pueden calificarse como bonos.


Secciones del operador


Los programadores que ya han trabajado con Haskell deben estar familiarizados con las secciones de operadores que permiten dar una breve descripción de los operadores aplicados. Por ejemplo, la estructura (*2) genera una función de argumento único, que devuelve el resultado de la multiplicación de este argumento por 2. Entonces, lo que quería era intentar escribir algo así en C ++. Apenas dicho que hecho!


 using namespace kari::underscore; std::vector<int> v{1,2,3,4,5}; std::accumulate(v.begin(), v.end(), 0, _+_); // result: 15 std::transform(v.begin(), v.end(), v.begin(), _*2); // v = 2, 3, 6, 8, 10 std::transform(v.begin(), v.end(), v.begin(), -_); // v = -2,-3,-6,-8,-10 

Composición de la función


Y, por supuesto, no sería un loco si no hubiera intentado escribir una composición de funciones . Como operador de composición, elegí el operator * como el más cercano (por lo que parece) de todos los símbolos disponibles para el signo de composición en matemáticas. También lo usé para aplicar la función resultante para un argumento. Entonces, eso es lo que obtuve:


 using namespace kari::underscore; // 1 std::cout << (_*2) * (_+2) * 4 << std::endl; // output: 12 // 2 std::cout << 4 * (_*2) * (_+2) << std::endl; // output: 10 

  1. La composición de funciones (*2) y (+2) se aplica a 4 . (4 + 2) * 2 = 12
  2. La función (*2) se aplica a 4 y luego aplicamos (+2) al resultado. (4 * 2 + 2) = 10

De la misma manera, puede crear composiciones bastante complejas en un estilo libre de puntos , pero tenga en cuenta que solo los programadores de Haskell lo entenderán :)


 // (. (+2)) (*2) $ 10 == 24 // haskell analog std::cout << (_*(_+2))(_*2) * 10 << std::endl; // output: 24 // ((+2) .) (*2) $ 10 == 22 // haskell analog std::cout << ((_+2)*_)(_*2) * 10 << std::endl; // output: 22 

Conclusión


Creo que antes estaba bastante claro que no hay necesidad de usar estas técnicas en proyectos reales. Pero aún así, debo mencionar eso. Después de todo, mi objetivo era probarme a mí mismo y verificar el nuevo estándar C ++. ¿Sería capaz de hacer esto? ¿Y sería C ++? Bueno, supongo que acabas de ver que ambos hemos hecho eso. Y estoy realmente agradecido con todos los chicos que lo leyeron todo.

Source: https://habr.com/ru/post/es436488/


All Articles