Quais propriedades dos contras permitem a eliminação dos contros do módulo de recursão da cauda?

14

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 conspermite isso? Que outras funções além de consencapsular 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)
Maxpm
fonte
1
No link do explorador do compilador Godbolt, sua função possui 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 multiplicidade 1 * xdisso que não estava presente na fonte, mesmo se fizermos uma floatversão. gcc.godbolt.org/z/eqwine (e gcc só consegue com -ffast-math.)
Peter Cordes
@PeterCordes Good catch. O return 0foi corrigido. A multiplicação por 1 é interessante. Não sei bem o que fazer disso.
Maxpm 16/03/19
Eu acho que é um efeito colateral da maneira como o GCC se transforma ao transformá-lo em um loop. Claramente, o gcc tem algumas otimizações perdidas aqui, por exemplo, perdê-lo para floatfora -ffast-math, mesmo que o mesmo valor seja multiplicado todas as vezes. (Excepto para o 1.0f` que pode ser o ponto de fura?)
Pedro Cordes

Respostas:

12

Embora o GCC provavelmente use regras ad-hoc, você pode derivá-las da seguinte maneira. Vou usar powpara ilustrar, já que você está footão vagamente definido. Além disso, foopode 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 dos fooretornos de estrutura representados por variáveis ​​de atribuição única que são passadas para fooargumentos adicionais. fooentão se torna um recursivo da caudavoidfunção de retorno. Nenhuma inteligência específica é necessária para isso.

Voltando a pow, primeiro, transforme-se no estilo de passagem contínua . powtorna-se:

pow(x, n):
    return pow2(x, n, x => x)

pow2(x, n, k):
    if n == 0: return k(1)
    else if n == 1: return k(x)
    else: return pow2(x, n-1, y => k(x*y))

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:

pow(x, n):
    return pow2(x, n, Nil)

pow2(x, n, k):
    if n == 0: return applyPow(k, 1)
    else if n == 1: return applyPow(k, x)
    else: return pow2(x, n-1, Cons(x, k))

applyPow(k, acc):
    match k with:
        case Nil: return acc
        case Cons(x, k):
            return applyPow(k, x*acc)

O que applyPow(k, acc)faz é pegar uma lista, ou seja, monóide livre, curtir k=Cons(x, Cons(x, Cons(x, Nil)))e entrar nela x*(x*(x*acc)). Mas, como *é associativo e geralmente forma um monóide com a unidade 1, podemos reassociá-lo ((x*x)*x)*acce, por simplicidade, 1começar a produzir (((1*x)*x)*x)*acc. O principal é que podemos realmente calcular parcialmente o resultado antes mesmo de o termos acc. Isso significa que, em vez de distribuir kcomo uma lista que é essencialmente uma "sintaxe" incompleta que iremos "interpretar" no final, podemos "interpretar" à medida que avançamos. O resultado é que podemos substituir Nilpela unidade do monóide, 1neste caso, e Conspela operação do monóide *, e agora krepresenta o "produto em execução".applyPow(k, acc)torna-se exatamente o k*accque podemos alinhar de volta pow2e simplificar a produção:

pow(x, n):
    return pow2(x, n, 1)

pow2(x, n, k):
    if n == 0: return k
    else if n == 1: return k*x
    else: return pow2(x, n-1, k*x)

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 .

Derek Elkins deixou o SE
fonte
A técnica que o GCC usa no lugar do estilo de passagem para continuação que você mostra aqui é, acredito, o formulário estático de atribuição única.
Davislor
@Davislor Embora relacionado ao CPS, o SSA não afeta o fluxo de controle de um procedimento nem reifica a pilha (ou introduz estruturas de dados que precisariam ser alocadas dinamicamente). Em relação ao SSA, o CPS "faz muito", e é por isso que o Formulário Normal Administrativo (ANF) é mais adequado ao SSA. Portanto, o GCC usa o SSA, mas o SSA não faz com que a pilha de controle seja visível como uma estrutura de dados manipulável.
Derek Elkins saiu de SE
Certo. Respondi a: “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. ”Minha resposta, da mesma forma, foi mostrar que a transformação é teoricamente justificada, sem dizer que é o método de implementação que qualquer compilador usa. (Embora, como você sabe, muitos compiladores fazer transformar um programa em CPS durante a otimização.)
Davislor
8

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:

  • adicionando qualquer um desses tipos de números em vez de multiplicar
  • concatenação de strings ( "a"+"b"+"c"é "abc"se você começa com "ab"+"c"ou "a"+"bc")
  • Emendando duas listas juntas. [a]++[b]++[c]é similarmente [a,b,c]de trás para frente ou de frente para trás.
  • consna cabeça e no rabo, se você pensa na cabeça como uma lista única. Isso é apenas concatenar duas listas.
  • tomando a união ou a interseção de conjuntos
  • Booleano e Booleano ou
  • bit a bit &, |e^
  • composição das funções: ( fg ) ∘ h x = f ∘ ( gh ) x = f ( g ( h ( x )))
  • máximo e mínimo
  • módulo de adição p

Algumas coisas que não fazem:

  • subtração, porque 1- (1-2) ≠ (1-1) -2
  • xy = tan ( x + y ), porque tan (π / 4 + π / 4) é indefinido
  • multiplicação sobre os números negativos, porque -1 × -1 não é um número negativo
  • divisão de números inteiros, que tem todos os três problemas!
  • lógico não, porque ele tem apenas um operando, não dois
  • int print2(int x, int y) { return printf( "%d %d\n", x, y ); }, como print2( print2(x,y), z );e print2( 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 Semigroupclasse de tipo em sua biblioteca padrão. Ele chama a operação de um Semigroupoperador genérico <>. Como listas e cadeias são instâncias de Semigroup, 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 <>um Double? Bem, qualquer um! Haskell define os tipos Product Double, where (<>) = (*)(que é a própria definição em Haskell), e também Sum 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 tipo mempty. Sum, Producte listar cada um tem um elemento de identidade (0, 1 e [], respectivamente), portanto, são instâncias Monoide também Semigroup. (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:

module StylizedRec (pow) where

import Data.Monoid as DM

pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
 - itself n times.  This is already in Haskell as Data.Monoid.mtimes, but
 - let’s write it out as an example.
 -}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x      -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.

É 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 memptyem um dos casos, mas se não precisássemos disso, poderíamos ter feito isso com a classe de tipo mais geral Semigroup.

Vamos carregar este programa no GHCI e ver como ele funciona:

*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49

Lembre-se de como declaramos powpara um genérico Monoid, de quem chamamos a? Demos GHCI informação suficiente para deduzir que o tipo aaqui é Product Integer, que é um instancede Monoidcuja <>operação é inteiro multiplicação. Então se pow 2 4expande recursivamente para 2<>2<>2<>2, que é 2*2*2*2ou 16. 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 Monoidchamado Sum, cuja <>operação é +. Podemos tentar isso?

*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14

A mesma expansão agora nos dá, em 2+2+2+2vez de 2*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.

*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]

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 xpor [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]para x, obter uma lista de elementos para reduzir em <>, e em seguida, dobra-direita ou esquerda vezes na lista:

*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16

A versão com foldr1realmente existe na biblioteca padrão, como sconcatpara Semigroupe mconcatpara Monoid. Ele faz uma dobra preguiçosa à direita na lista. Ou seja, ele se expande [Product 2,Product 2,Product 2,Product 2]para 2<>(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 calcula 8<>2, em seguida 16. É 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 whilelaç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]<>xso 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 Foldableestruturas 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ção xà 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 aplicado xà direita <>e à esquerda.

Observe também que a propriedade associativa permite reagrupar as operações de outras maneiras úteis, como dividir e conquistar:

times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n    = y <> y
          | otherwise = x <> y <> y
  where y = times x (n `quot` 2)

Ou paralelismo automático, em que cada encadeamento reduz um subintervalo a um valor que é então combinado com os outros.

Davislor
fonte
1
Podemos fazer um experimento para testar se a associatividade é a chave para a capacidade do GCC de fazer essa otimização: uma 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 um 1.0f * xque não estava presente na máquina abstrata C (mas que sempre dará um resultado idêntico). Então multiplicações n-1 do{res*=x;}while(--n!=1)são iguais às recursivas, portanto essa é uma otimização perdida.
Peter Cordes