Neste artigo, vou falar sobre uma das opções de curry e aplicação parcial das funções em C ++, que é meu favorito. Também vou mostrar minha própria implementação piloto dessa coisa e explicar o ponto de currying sem fórmula matemática complexa, tornando isso realmente simples para você. Também veremos o que há por trás da biblioteca kari.hpp que usaremos para funções de curry. De qualquer forma, há muitas coisas fascinantes por dentro, então seja bem-vindo!
Currying
Então, o que é currying? Eu acho que é uma daquelas palavras que você ouve dos programadores Haskell o tempo todo (depois da mônada , é claro). Essencialmente, a definição do termo é bastante simples; portanto, os leitores que já escreveram em idiomas do tipo ML ou Haskell , ou que sabem o que isso significa em outros lugares, ficam à vontade para pular esta seção.
Currying - é a técnica de transformar uma função que leva N argumentos em uma função, que pega um único argumento e retorna a função do próximo argumento, e continua até que retornemos a função do último argumento, que representará o resultado geral. Eu acho que ajuda se eu mostrar exemplos:
int sum2(int lhs, int rhs) { return lhs + rhs; }
Aqui temos uma função de adição binária. E se quisermos transformá-lo em função de variável única? Na verdade, é muito simples:
auto curried_sum2(int lhs) { return [=](int rhs) { return sum2(lhs, rhs); }; }
Não, o que fizemos? Pegamos um valor com base em um único argumento chamado lambda que, por sua vez, pega o segundo argumento e executa a adição em si. Como resultado, podemos aplicar a função curried_sum2
aos nossos argumentos um por um:
E esse é realmente o objetivo da operação de curry. Claro, é possível fazê-lo com funções de qualquer aridade - vai funcionar absolutamente da mesma maneira. Retornaremos uma função ao curry de argumentos N-1 toda vez que extrairmos o valor de outro 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); }; }; }
Aplicação parcial
Aplicação parcial - é uma maneira de chamar funções de N argumentos quando eles pegam apenas uma parte dos argumentos e retornam outra função dos argumentos restantes.
Nesse sentido, deve-se notar que em linguagens como Haskell esse processo funciona automaticamente, nas costas de um programador. O que estamos tentando fazer aqui é executá-lo explicitamente, sum3
, chamar nossa função sum3
assim: sum3(38,3)(1)
ou talvez assim: sum3(38)(3,1)
. Além disso, se uma função retornar outra função que foi curry, ela também poderá ser chamada usando a lista dos argumentos da primeira função. Vamos ver o exemplo:
int boo(int v1, int v2) { return v1 + v2; } auto foo(int v1, int v2) { return kari::curry(boo, v1 + v2); }
Na verdade, temos um pouco de vantagem aqui, mostrando um exemplo do uso do kari.hpp , então sim, ele faz isso.
Estabelecendo as metas
Antes de escrevermos algo, é necessário (ou desejável) entender o que queremos ter no final. E queremos ter a oportunidade de curry e aplicar parcialmente qualquer função que possa ser chamada em C ++. Quais são:
- lambdas (incluindo genéricos)
- objetos de função (functors)
- funções de qualquer aridade (incluindo modelos)
- funções variadicas
- métodos de uma classe
Funções variáveis podem ser alteradas especificando um número exato de argumentos que queremos curry. Interação padrão com std :: bind e seus resultados também são desejáveis. E, é claro, precisamos de uma oportunidade para aplicar funções de múltiplas variáveis e chamar funções aninhadas, para que pareça que estivemos trabalhando com uma função ao curry.
E não devemos esquecer o desempenho também. Precisamos minimizar os custos computacionais dos wrappers, a transferência de argumentos e seu armazenamento. Isso significa que precisamos mudar, em vez de copiar, armazenar apenas o que realmente precisamos e retornar (com mais remoção) os dados o mais rápido possível.
Autor, você está tentando inventar std::bind
one novamente!
Sim e não std::bind
é sem dúvida uma ferramenta poderosa e comprovada, e não pretendo escrever seu assassino ou alternativa. Sim, pode ser usado para aplicação parcial e explícita (com a especificação exata de quais argumentos estamos aplicando, onde e quantos). Mas é certo que não é a abordagem mais conveniente, sem mencionar que nem sempre é aplicável, pois precisamos conhecer a aridade da função e escrever ligações específicas, dependendo disso. Por exemplo:
int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; }
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)
Retorna um objeto de função do tipo curry_t
(uma função ao curry) com argumentos opcionais args
aplicados ou com o resultado da aplicação dos argumentos à função dada f
(é a função ser nula ou os argumentos transferidos foram suficientes para chamá-lo).
Se o parâmetro f
contiver a função que já foi curry, ele retornará sua cópia com os argumentos args
aplicados.
kari::curryV(F&& f, Args&&... args)
Permite curry funções com número variável de argumentos. Depois disso, essas funções podem ser chamadas usando o operador ()
sem argumentos. Por exemplo:
auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); c2();
Se o parâmetro f
contiver uma função que já foi curry, ele retornará sua cópia com tipo de aplicativo alterado para número variável de argumentos com os argumentos args
aplicados.
kari::curryN(F&& f, Args&&... args)
Permite curry funções com número variável de argumentos, especificando um número exato N
de argumentos que queremos aplicar (exceto os dados em args
). Por exemplo:
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;
Se o parâmetro f
contiver uma função que já foi curry, ele retornará sua cópia com tipo de aplicativo alterado para N argumentos com os argumentos args
aplicados.
kari::is_curried<F>, kari::is_curried_v<F>
Algumas estruturas auxiliares para verificar se uma função já foi curry. Por exemplo:
const auto l = [](int v1, int v2){ return v1 + v2; }; const auto c = curry(l);
kari::curry_t::operator()(As&&... as)
O operador permite a aplicação total ou parcial de uma função com curry. Retorna a função ao curry dos argumentos restantes da função inicial F
, ou o valor dessa função obtida por sua aplicação no backlog de argumentos antigos e novos argumentos as
. Por exemplo:
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);
Se você chamar uma função ao curry sem argumentos usando curryV
ou curryN
, ela será chamada se houver argumentos suficientes. Caso contrário, ele retornará uma função parcialmente aplicada. Por exemplo:
auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42);
Detalhes de implementação
Ao fornecer detalhes de implementação, vou usar o C ++ 17 para manter o texto do artigo curto e evitar explicações desnecessárias e empilhadas na SFINAE , além de exemplos de implementações que tive que adicionar no C ++ 14 padrão. Tudo isso você pode encontrar no repositório do projeto, onde você também pode adicioná-lo aos seus favoritos :)
make_curry(F&& f, std::tuple<Args...>&& args)
Uma função auxiliar que cria um objeto de função curry_t
ou aplica a função fornecida f
aos 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()); }
Agora, há duas coisas interessantes sobre essa função:
- aplicamos aos argumentos apenas se for possível chamar esses argumentos e o contador de aplicativos
N
estiver em zero - se a função não puder ser chamada, consideramos essa chamada como aplicação parcial e criamos um objeto de função
curry_t
contendo a função e os argumentos
struct curry_t
O objeto de função deveria armazenar o backlog de argumentos e a função que chamaremos ao aplicá-lo no final. Esse objeto é o que vamos chamar e 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_; };
Há várias razões pelas quais armazenamos o backlog de argumentos args_
em std :: tuple :
1) situações com std :: ref são tratadas automaticamente para armazenar referências quando necessário, por padrão, com base no valor
2) aplicação conveniente de uma função de acordo com seus argumentos ( std :: apply )
3) está pronto, para que você não precise escrevê-lo do zero :)
Também armazenamos o objeto que chamamos e a função f_
por seu valor, e tenha cuidado ao escolher o tipo ao criar um (vou expandir esse problema abaixo), movendo-o ou copiando-o usando referência universal em o construtor.
Um parâmetro de modelo N
atua como um contador de aplicativo para funções variadas.
curry_t::operator()(const As&...)
E, claro, o que faz tudo funcionar - o operador que chama o objeto de função.
template < std::size_t N, typename F, typename... Args > struct curry_t {
O operador de chamada possui quatro funções sobrecarregadas.
Uma função sem parâmetros que permite iniciar a aplicação da função curryV
(criada por curryV
ou curryN
). Aqui, reduzimos o contador do aplicativo para zero, deixando claro que a função está pronta para ser aplicada e, em seguida, fornecemos tudo o que é necessário para que a função make_curry
.
Uma função de um único argumento que diminui o contador do aplicativo em 1 (se não estiver em zero) e coloca nosso novo argumento a
no backlog dos argumentos args_
e transfere tudo isso para make_curry
.
Uma função variável que é realmente um truque para aplicação parcial de vários argumentos. O que ele faz é aplicá-los recursivamente, um por um. Agora, existem duas razões pelas quais eles não podem ser aplicados de uma só vez:
- o contador de aplicativos pode chegar a zero antes que não haja mais argumentos
- a função
f_
pode ser chamada anteriormente e retornar outra função ao curry, portanto todos os próximos argumentos serão destinados a ela
A última função atua como uma ponte entre chamar curry_t
usando lvalue e chamar funções usando rvalue .
As tags de funções qualificadas para ref tornam todo o processo quase mágico. Para resumir, com a ajuda deles, sabemos que um objeto foi chamado usando a referência rvalue e podemos apenas mover os argumentos em vez de copiá-los no final, chamando a função make_curry
. Caso contrário, teríamos que copiar os argumentos para ainda ter a oportunidade de chamar essa função novamente, garantindo que os argumentos ainda estejam lá.
Bónus
Antes de prosseguir para a conclusão, gostaria de mostrar alguns exemplos do açúcar sintático que eles têm no kari.hpp, que podem ser qualificados como bônus.
Seções do operador
Os programadores que já trabalharam com Haskell devem estar familiarizados com as seções do operador, permitindo uma breve descrição dos operadores aplicados. Por exemplo, a estrutura (*2)
gera uma função de argumento único, retornando o resultado da multiplicação desse argumento por 2. Então, o que eu queria era tentar escrever algo assim em C ++. Mal disse o que fez!
using namespace kari::underscore; std::vector<int> v{1,2,3,4,5}; std::accumulate(v.begin(), v.end(), 0, _+_);
Composição da função
E é claro que eu não seria um maluco completo se não tentasse escrever uma composição de função . Como operador de composição, escolhi o operator *
como o mais próximo (pelo que parece) de todos os símbolos disponíveis para o sinal de composição em matemática. Também usei para aplicar a função resultante a um argumento. Então, é isso que eu tenho:
using namespace kari::underscore;
- composição de funções
(*2)
e (+2)
é aplicada a 4
. (4 + 2) * 2 = 12
- a função
(*2)
é aplicada a 4
e depois aplicamos (+2)
ao resultado. (4 * 2 + 2) = 10
Da mesma forma que você pode criar composições bastante complexas no estilo pointfree , mas lembre-se de que apenas os programadores Haskell entenderão isso :)
Conclusão
Eu acho que era bastante claro antes que não há necessidade de usar essas técnicas em projetos reais. Mas, ainda assim, devo mencionar isso. Afinal, meu objetivo era provar a mim mesmo e verificar o novo padrão C ++. Eu seria capaz de fazer isso? E seria C ++? Bem, acho que você acabou de ver como nós dois fizemos isso. E sou muito grato a todos os caras que leram a coisa toda.