Eu descobri o comando "time" no Unix hoje e pensei em usá-lo para verificar a diferença nos tempos de execução entre funções recursivas de cauda e recursivas normais em Haskell.
Eu escrevi as seguintes funções:
--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
fac' 1 y = y
fac' x y = fac' (x-1) (x*y)
--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)
Eles são válidos tendo em mente que eram apenas para uso com este projeto, então não me preocupei em verificar se há zeros ou números negativos.
No entanto, ao escrever um método principal para cada um, compilá-los e executá-los com o comando "time", ambos tinham tempos de execução semelhantes com a função recursiva normal ultrapassando a recursiva final. Isso é contrário ao que eu ouvi em relação à otimização recursiva de cauda no lisp. Qual é o motivo disso?
haskell
optimization
lazy-evaluation
tail-recursion
tail-call-optimization
malandro haskell
fonte
fonte
fac
é mais ou menos como o ghc calculaproduct [n,n-1..1]
usando uma função auxiliarprod
, mas é claroproduct [1..n]
que seria mais simples. Só posso supor que eles não o tornaram estrito em seu segundo argumento, alegando que esse é o tipo de coisa que o ghc tem certeza de que pode compilar em um acumulador simples.Respostas:
Haskell usa avaliação preguiçosa para implementar recursão, então trata qualquer coisa como uma promessa de fornecer um valor quando necessário (isso é chamado de conversão). Thunks são reduzidos apenas o necessário para prosseguir, nada mais. Isso se assemelha à maneira como você simplifica uma expressão matematicamente, portanto, é útil pensar dessa maneira. O fato de que a ordem de avaliação não é especificada por seu código permite que o compilador faça muitas otimizações ainda mais inteligentes do que apenas a eliminação de chamada final a que você está acostumado. Compile com
-O2
se quiser otimização!Vamos ver como avaliamos
facSlow 5
como um estudo de caso:Assim como você se preocupa, temos um acúmulo de números antes que qualquer cálculo aconteça, mas ao contrário de você se preocupa, não há uma pilha de
facSlow
chamadas de função esperando para terminar - cada redução é aplicada e vai embora, deixando um quadro de pilha em seu wake (isso porque(*)
é estrito e, portanto, aciona a avaliação de seu segundo argumento).As funções recursivas de Haskell não são avaliadas de uma forma muito recursiva! A única pilha de chamadas suspensas são as próprias multiplicações. Se
(*)
for visto como um construtor de dados estrito, isso é conhecido como recursão protegida (embora seja geralmente referida como tal com não construtores de dados estritos, onde o que resta em seu rastro são os construtores de dados - quando forçado por acesso posterior).Agora vamos dar uma olhada na cauda recursiva
fac 5
:Então você pode ver como a recursão da cauda por si só não economizou tempo ou espaço. Ele não apenas leva mais etapas gerais do que
facSlow 5
, mas também cria uma conversão aninhada (mostrada aqui como{...}
) - precisando de um espaço extra para ele - que descreve a computação futura, as multiplicações aninhadas a serem realizadas.Essa conversão é então desvendada ao atravessá- la até o final, recriando a computação na pilha. Também existe o perigo de causar estouro de pilha com cálculos muito longos, para ambas as versões.
Se quisermos otimizar isso manualmente, tudo o que precisamos fazer é torná-lo rígido. Você pode usar o operador de aplicativo estrito
$!
para definirIsso obriga
facS'
a ser estrito em seu segundo argumento. (Já é estrito em seu primeiro argumento porque tem que ser avaliado para decidir qual definiçãofacS'
aplicar.)Às vezes, a rigidez pode ajudar enormemente, às vezes é um grande erro porque a preguiça é mais eficiente. Aqui é uma boa ideia:
Que é o que você queria alcançar, eu acho.
Resumo
-O2
foldr
e,foldl
por exemplo, e teste-os uns contra os outros.Experimente estes dois:
foldl1
é recursivo na cauda, enquantofoldr1
executa recursão protegida de modo que o primeiro item seja imediatamente apresentado para processamento / acesso posterior. (O primeiro "coloca entre parênteses" à esquerda de uma vez,(...((s+s)+s)+...)+s
forçando sua lista de entrada totalmente ao seu fim e construindo uma grande quantidade de computação futura muito mais cedo do que seus resultados completos são necessários; o segundo parênteses à direita gradualmentes+(s+(...+(s+s)...))
, consumindo a entrada listar pouco a pouco, para que tudo funcione em espaço constante, com otimizações).Pode ser necessário ajustar o número de zeros dependendo de qual hardware você está usando.
fonte
Deve ser mencionado que a
fac
função não é uma boa candidata para recursão protegida. A recursão da cauda é o caminho a percorrer aqui. Devido à preguiça, você não está obtendo o efeito do TCO em suafac'
função porque os argumentos do acumulador continuam construindo grandes thunks, que quando avaliados exigirão uma pilha enorme. Para evitar isso e obter o efeito desejado do TCO, você precisa tornar esses argumentos do acumulador estritos.Se você compilar usando
-O2
(ou apenas-O
) GHC, provavelmente fará isso sozinho na fase de análise de rigidez .fonte
$!
que comBangPatterns
, mas esta é uma boa resposta. Especialmente a menção à análise de rigidez.Você deve verificar o artigo wiki sobre recursão da cauda em Haskell . Em particular, por causa da avaliação da expressão, o tipo de recursão que você deseja é a recursão protegida . Se você descobrir os detalhes do que está acontecendo por baixo do capô (na máquina abstrata de Haskell), obterá o mesmo tipo de coisa que a recursão de cauda em linguagens estritas. Junto com isso, você tem uma sintaxe uniforme para funções preguiçosas (a recursão final vai amarrá-lo a uma avaliação estrita, enquanto a recursão protegida funciona mais naturalmente).
(E ao aprender Haskell, o resto das páginas wiki são fantásticas também!)
fonte
Se bem me lembro, o GHC otimiza automaticamente funções recursivas simples em funções otimizadas recursivas de cauda.
fonte