Estou familiarizado com a idéia de eliminação básica da recursão da cauda, em que funções que retornam o resultado direto de uma chamada para elas mesmas podem ser reescritas como loops iterativos.
foo(...):
# ...
return foo(...)
Também entendo que, como um caso especial, a função ainda pode ser reescrita se a chamada recursiva for agrupada em uma chamada para cons
.
foo(...):
# ...
return (..., foo(...))
Que propriedade de cons
permite isso? Que outras funções além de cons
encapsular uma chamada recursiva sem destruir nossa capacidade de reescrevê-la iterativamente?
O GCC (mas não o Clang) é capaz de otimizar este exemplo de " multiplicação do módulo de recursão da cauda" , mas não está claro qual mecanismo permite descobrir isso ou como ele faz suas transformações.
pow(x, n):
if n == 0: return 1
else if n == 1: return x
else: return x * pow(x, n-1)
if(n==0) return 0;
(não retorna 1 como em sua pergunta).x^0 = 1
, então isso é um bug. Não que isso importe para o resto da questão; o asm iterativo verifica primeiro esse caso especial. Mas, estranhamente, a implementação iterativa introduz uma multiplicidade1 * x
disso que não estava presente na fonte, mesmo se fizermos umafloat
versão. gcc.godbolt.org/z/eqwine (e gcc só consegue com-ffast-math
.)return 0
foi corrigido. A multiplicação por 1 é interessante. Não sei bem o que fazer disso.float
fora-ffast-math
, mesmo que o mesmo valor seja multiplicado todas as vezes. (Excepto para o 1.0f` que pode ser o ponto de fura?)Respostas:
Embora o GCC provavelmente use regras ad-hoc, você pode derivá-las da seguinte maneira. Vou usar
pow
para ilustrar, já que você estáfoo
tão vagamente definido. Além disso,foo
pode ser melhor entendido como uma instância de otimização de última chamada em relação a variáveis de atribuição única, como o idioma Oz e como discutido em Conceitos, técnicas e modelos de programação de computadores . O benefício do uso de variáveis de atribuição única é que ele permite permanecer dentro de um paradigma de programação declarativa. Essencialmente, você pode ter cada campo dosfoo
retornos de estrutura representados por variáveis de atribuição única que são passadas parafoo
argumentos adicionais.foo
então se torna um recursivo da caudavoid
função de retorno. Nenhuma inteligência específica é necessária para isso.Voltando a
pow
, primeiro, transforme-se no estilo de passagem contínua .pow
torna-se:Todas as chamadas são chamadas finais agora. No entanto, a pilha de controle foi movida para os ambientes capturados nos fechamentos que representam as continuações.
Em seguida, defuncionalize as continuações. Como existe apenas uma chamada recursiva, a estrutura de dados resultante que representa as continuações desfuncionalizadas é uma lista. Nós temos:
O que
applyPow(k, acc)
faz é pegar uma lista, ou seja, monóide livre, curtirk=Cons(x, Cons(x, Cons(x, Nil)))
e entrar nelax*(x*(x*acc))
. Mas, como*
é associativo e geralmente forma um monóide com a unidade1
, podemos reassociá-lo((x*x)*x)*acc
e, por simplicidade,1
começar a produzir(((1*x)*x)*x)*acc
. O principal é que podemos realmente calcular parcialmente o resultado antes mesmo de o termosacc
. Isso significa que, em vez de distribuirk
como uma lista que é essencialmente uma "sintaxe" incompleta que iremos "interpretar" no final, podemos "interpretar" à medida que avançamos. O resultado é que podemos substituirNil
pela unidade do monóide,1
neste caso, eCons
pela operação do monóide*
, e agorak
representa o "produto em execução".applyPow(k, acc)
torna-se exatamente ok*acc
que podemos alinhar de voltapow2
e simplificar a produção:Uma versão em estilo recursiva e de passagem de acumulador do original
pow
.Obviamente, não estou dizendo que o GCC faz todo esse raciocínio em tempo de compilação. Não sei qual lógica o GCC usa. Meu argumento é simplesmente ter feito esse raciocínio uma vez; é relativamente fácil reconhecer o padrão e traduzir imediatamente o código-fonte original para esse formato final. No entanto, a transformação CPS e a transformação de defuncionalização são completamente gerais e mecânicas. A partir daí, técnicas de fusão, desmatamento ou supercompilação podem ser usadas para tentar eliminar as continuações reificadas. As transformações especulativas poderiam ser descartadas se não fosse possível eliminar toda a alocação das continuações reificadas. Suspeito, porém, que isso seria muito caro para ser feito o tempo todo, em total generalidade, portanto, abordagens mais ad-hoc.
Se você quiser ser ridículo, verifique o artigo Recycling Continuations, que também usa o CPS e as representações de continuações como dados, mas faz algo semelhante, mas diferente, aos contras-módulo-recursão da cauda. Isso descreve como você pode produzir algoritmos de reversão de ponteiro por transformação.
Esse padrão de transformação e desfuncionalização do CPS é uma ferramenta bastante poderosa para a compreensão e é usada com bons resultados em uma série de artigos que listo aqui .
fonte
Eu vou andar por aí por um tempo, mas há um ponto.
Semigrupos
A resposta é a propriedade associativa da operação de redução binária .
Isso é bastante abstrato, mas a multiplicação é um bom exemplo. Se x , y e z são alguns números naturais (ou inteiros, ou números racionais, ou números reais ou números complexos ou N × N matrizes, ou qualquer de um monte mais coisas), então x × y é o mesmo tipo de número como x e y . Começamos com dois números, por isso é uma operação binária e obtivemos um, então reduzimos a contagem de números que tínhamos em um, tornando isso uma operação de redução. E ( x × y ) × z é sempre o mesmo que x × ( y ×z ), que é a propriedade associativa.
(Se você já sabe tudo isso, pode pular para a próxima seção.)
Mais algumas coisas que você costuma ver na ciência da computação que funcionam da mesma maneira:
"a"+"b"+"c"
é"abc"
se você começa com"ab"+"c"
ou"a"+"bc"
)[a]++[b]++[c]
é similarmente[a,b,c]
de trás para frente ou de frente para trás.cons
na cabeça e no rabo, se você pensa na cabeça como uma lista única. Isso é apenas concatenar duas listas.&
,|
e^
Algumas coisas que não fazem:
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, comoprint2( print2(x,y), z );
eprint2( x, print2(y,z) );
tem saída diferente.É um conceito útil o suficiente que o denominamos. Um conjunto com uma operação que possui essas propriedades é um semigrupo . Portanto, os números reais sob multiplicação são um semigrupo. E sua pergunta acaba sendo uma das maneiras pelas quais esse tipo de abstração se torna útil no mundo real. As operações de semigrupos podem ser otimizadas da maneira que você está perguntando.
Tente isso em casa
Até onde eu sei, essa técnica foi descrita pela primeira vez em 1974, no artigo de Daniel Friedman e David Wise, “Dobrando recursões estilizadas em iterações” , embora eles assumissem mais algumas propriedades do que precisavam.
Haskell é uma ótima linguagem para ilustrar isso, porque possui a
Semigroup
classe de tipo em sua biblioteca padrão. Ele chama a operação de umSemigroup
operador genérico<>
. Como listas e cadeias são instâncias deSemigroup
, suas instâncias definem<>
como o operador de concatenação++
, por exemplo. E com a importação correta,[a] <> [b]
é um alias para[a] ++ [b]
, o que é[a,b]
.Mas e os números? Nós acabamos de ver que tipos numéricos são semigroups sob qualquer adição ou multiplicação! Então qual chega a ser
<>
umDouble
? Bem, qualquer um! Haskell define os tiposProduct Double
,where (<>) = (*)
(que é a própria definição em Haskell), e tambémSum Double
,where (<>) = (+)
.Uma das rugas é que você usou o fato de que 1 é a identidade multiplicativa. Um semigrupo com uma identidade é chamado monóide e é definido no pacote Haskell
Data.Monoid
, que chama o elemento de identidade genérico de uma classe de tipomempty
.Sum
,Product
e listar cada um tem um elemento de identidade (0, 1 e[]
, respectivamente), portanto, são instânciasMonoid
e tambémSemigroup
. (Não deve ser confundida com uma mônada , então esqueça que eu as criei.)São informações suficientes para converter seu algoritmo em uma função Haskell usando monoides:
É importante ressaltar que esse é um semigrupo de módulo de recursão de cauda: todo caso é um valor, uma chamada recursiva de cauda ou o produto de semigrupo de ambos. Além disso, esse exemplo foi usado
mempty
em um dos casos, mas se não precisássemos disso, poderíamos ter feito isso com a classe de tipo mais geralSemigroup
.Vamos carregar este programa no GHCI e ver como ele funciona:
Lembre-se de como declaramos
pow
para um genéricoMonoid
, de quem chamamosa
? Demos GHCI informação suficiente para deduzir que o tipoa
aqui éProduct Integer
, que é uminstance
deMonoid
cuja<>
operação é inteiro multiplicação. Então sepow 2 4
expande recursivamente para2<>2<>2<>2
, que é2*2*2*2
ou16
. Por enquanto, tudo bem.Mas nossa função usa apenas operações monóides genéricas. Anteriormente, eu disse que existe outra instância de
Monoid
chamadoSum
, cuja<>
operação é+
. Podemos tentar isso?A mesma expansão agora nos dá, em
2+2+2+2
vez de2*2*2*2
. Multiplicação é adição como exponenciação é multiplicação!Mas dei outro exemplo de um monóide Haskell: listas, cuja operação é concatenação.
Escrever
[2]
diz ao compilador que esta é uma lista,<>
nas listas++
,[2]++[2]++[2]++[2]
é assim[2,2,2,2]
.Finalmente, um algoritmo (dois, de fato)
Simplesmente substituindo
x
por[x]
, você converte o algoritmo genérico que usa o módulo de recursão de um semigrupo em um que cria uma lista. Qual lista? A lista de elementos aos quais o algoritmo se aplica<>
. Como também usamos apenas operações de semigrupos que as listas possuem, a lista resultante será isomórfica ao cálculo original. E como a operação original era associativa, podemos igualmente avaliar os elementos de trás para frente ou de frente para trás.Se o seu algoritmo chegar a um caso base e terminar, a lista ficará vazia. Como o caso terminal retornou algo, esse será o elemento final da lista e, portanto, terá pelo menos um elemento.
Como você aplica uma operação de redução binária a todos os elementos de uma lista em ordem? Isso mesmo, uma dobra. Então você pode substituir
[x]
parax
, obter uma lista de elementos para reduzir em<>
, e em seguida, dobra-direita ou esquerda vezes na lista:A versão com
foldr1
realmente existe na biblioteca padrão, comosconcat
paraSemigroup
emconcat
paraMonoid
. Ele faz uma dobra preguiçosa à direita na lista. Ou seja, ele se expande[Product 2,Product 2,Product 2,Product 2]
para2<>(2<>(2<>(2)))
.Isso não é eficiente nesse caso, porque você não pode fazer nada com os termos individuais até gerar todos eles. (Em um momento, discuti aqui sobre quando usar dobras à direita e quando usar dobras estritas à esquerda, mas isso foi longe demais.)
A versão com
foldl1'
é uma dobra esquerda estritamente avaliada. Ou seja, uma função recursiva da cauda com um acumulador estrito. Isso avalia(((2)<>2)<>2)<>2
, calculado imediatamente e não depois, quando necessário. (Pelo menos, não há atraso dentro da dobra em si:. Da lista a ser dobrada é gerado aqui por outra função que pode conter avaliação lenta) Assim, os calcula de dobragem(4<>2)<>2
, em seguida, imediatamente calcula8<>2
, em seguida16
. É por isso que precisamos que a operação fosse associativa: acabamos de alterar o agrupamento dos parênteses!A estrita dobra à esquerda é o equivalente ao que o GCC está fazendo. O número mais à esquerda no exemplo anterior é o acumulador, neste caso, um produto em execução. A cada etapa, é multiplicado pelo próximo número da lista. Outra maneira de expressar isso é: você itera sobre os valores a serem multiplicados, mantendo o produto em execução em um acumulador e, a cada iteração, multiplica o acumulador pelo próximo valor. Ou seja, é um
while
laço disfarçado.Às vezes, pode ser feito com a mesma eficiência. O compilador pode otimizar a estrutura de dados da lista na memória. Em teoria, ele possui informações suficientes no momento da compilação para descobrir que deve fazê-lo aqui:
[x]
é um singleton,[x]<>xs
o mesmo écons x xs
. Cada iteração da função pode reutilizar o mesmo quadro de pilha e atualizar os parâmetros no local.Uma dobra direita ou uma dobra estrita à esquerda podem ser mais apropriadas, em um caso específico, então saiba qual você deseja. Também existem algumas coisas que apenas uma dobra à direita pode fazer (como gerar saída interativa sem aguardar toda a entrada e operar em uma lista infinita). Aqui, porém, estamos reduzindo uma sequência de operações a um valor simples, de modo que uma dobra esquerda estrita é o que queremos.
Portanto, como você pode ver, é possível otimizar automaticamente o módulo de recursão da cauda em qualquer semigrupo (um exemplo é um dos tipos numéricos usuais em multiplicação) para uma dobra preguiçosa à direita ou uma dobra estrita à esquerda, em uma linha de Haskell.
Generalizando mais
Os dois argumentos da operação binária não precisam ser do mesmo tipo, desde que o valor inicial seja do mesmo tipo que o resultado. (É claro que você sempre pode inverter os argumentos para corresponder à ordem do tipo de dobra que você está fazendo, esquerda ou direita.) Portanto, você pode adicionar patches repetidamente a um arquivo para obter um arquivo atualizado ou começar com um valor inicial de 1.0, divida por números inteiros para acumular um resultado de ponto flutuante. Ou acrescente elementos à lista vazia para obter uma lista.
Outro tipo de generalização é aplicar as dobras não em listas, mas em outras
Foldable
estruturas de dados. Frequentemente, uma lista vinculada linear imutável não é a estrutura de dados que você deseja para um determinado algoritmo. Uma questão que não abordamos acima é que é muito mais eficiente adicionar elementos à frente de uma lista do que à parte de trás e, quando a operação não é comutativa, a aplicaçãox
à esquerda e à direita da operação não é o mesmo. Portanto, você precisaria usar outra estrutura, como um par de listas ou árvore binária, para representar um algoritmo que poderia ser aplicadox
à direita<>
e à esquerda.Observe também que a propriedade associativa permite reagrupar as operações de outras maneiras úteis, como dividir e conquistar:
Ou paralelismo automático, em que cada encadeamento reduz um subintervalo a um valor que é então combinado com os outros.
fonte
pow(float x, unsigned n)
versão gcc.godbolt.org/z/eqwine apenas otimiza com-ffast-math
(o que implica-fassociative-math
. O ponto flutuante estrito não é, obviamente, associativo, porque diferentes temporários = arredondamento diferente). Introduz um1.0f * x
que não estava presente na máquina abstrata C (mas que sempre dará um resultado idêntico). Então multiplicações n-1do{res*=x;}while(--n!=1)
são iguais às recursivas, portanto essa é uma otimização perdida.