Cosa può qualificarsi per una potenziale ottimizzazione della ricorsione della coda (TCO) o dell'eliminazione della ricorsione della coda (TRE)

3

La domanda breve è: cosa può qualificarsi per l'ottimizzazione della ricorsione della coda (TCO) o l'eliminazione della ricorsione della coda (TRE) se il compilatore o l'interprete lo supporta.

Il riassunto di questa domanda è nella sezione " O è vero che, un modo più semplice di pensarci " di seguito.

Ho visualizzato la domanda Stack Overflow "Cos'è l'eliminazione coda-ricorsione?" pure, e leggere diverse descrizioni di come una ricorsione chiamata coda può accadere.

Primo:

a tail call is a subroutine call performed as the final action of a procedure (Wikipedia as of Jan 10, 2016)

Secondo:

when the value returned by the recursive call is itself immediately returned (Programming Interview Exposed, Wiley, 2013)

Terzo:

when a function calls itself, it cannot do any operation with this value that involves a local variable, and just return it. This call happens at the end of function, and so any local variables or "state" inside the stack (inside the current scope) can be thrown away

Penso che il secondo sia molto vicino al terzo, tranne che non ho visto esattamente cosa significa "immediatamente". Il primo mi ha fatto pensare che se restituisco n * fact(n-1) allora si qualifica, anche se più avanti in Wikipedia, dice che n * fact(n - 1) non è ricorsivo di coda, perché anche se si chiama sull'ultima riga, usa n , quindi non ha i requisiti per il TCO.

È interessante notare che forse il libro Programming Interview Exposed 2013, Wiley, ha bisogno di un'errata, che dice

int factorial( int n ) {
    if (n > 1) { 
        /* Recursive case */
        return factorial(n-1) * n; 
    } else { 
        /* Base case */
        return 1; 
    }
}

when the value returned by the recursive call is itself immediately returned, as in the preceding definition for factorial, the function is tail-recursive. Some compilers can perform tail call elimination on tail-recursive functions, an optimization that reuses the same stack frame for each recursive call

ma non penso che sia ricorsivo in coda, vero?

È importante chiamare l'ultima riga? Nella pagina Tail Call Optimization in Ruby si dice che il seguente codice può essere qualificato per il TCO:

def fact(n, acc=1)             # just assuming we pass in non-negative n
  return acc if n <= 1
  return fact(n-1, n*acc)
end

Ma non è vero che anche i seguenti si qualificano per il TCO?

def fact(n, acc=1)
  return fact(n-1, n*acc) if n > 1
  return acc if n <= 1
end

O non lo è, perché ha "affari incompiuti" - hai bisogno dello stack per ricordare dove il codice ha raggiunto e continua in seguito, quindi non puoi cancellare lo stack?

Che cosa succede se esegui operazioni matematiche, ma è come return 1 + fact(n-1) ? Cioè, stai aggiungendo una costante, 1 invece di toccare qualsiasi variabile locale, quindi non ci dovrebbero essere informazioni sullo stack che devono essere conservate ogni volta che ricorre. Ma se lo visualizzi come se avessi bisogno di ricordare 1 + (1 + (1 + (1 + ... , in effetti avrai ancora bisogno di più stack, a meno che il tipico TCO possa effettivamente ottimizzarlo a 4 + fact(...) senza bisogno di nuovi stack.

O è vero che, un modo più semplice di pensarci è:

Quando riesci a cancellare l'intero frame dello stack, perché non è necessario conservare nessuna di queste informazioni nello stack corrente quando effettui la chiamata ricorsiva, puoi cancellare l'intero frame dello stack e usare solo un "GO" TO ", invece di aggiungere un nuovo stack, può qualificarsi per il TCO? Quindi, almeno per questa chiamata ricorsiva, non ci sarà overflow dello stack.

Quindi, se è possibile cancellare l'intero frame dello stack, ciò significa che non sono necessarie variabili locali?

Che cosa succede se si tratta di una chiamata ricorsiva per attraversare un albero binario o rinominare tutti i file in una directory e tutte le relative sottodirectory, dal modulo "Programming_Ruby.pdf" a "Programming Ruby.pdf", e la ricorsione non è sul ultima riga, perché l'ultima riga ha un print "this folder done" ... quindi, puoi cancellare l'intero frame dello stack? Cioè, la posizione della prossima linea di codice da eseguire dipenderà anche dal frame dello stack?

Se la linea successiva da eseguire dipende dallo stack, allora forse può ridursi a queste 2 regole?

  1. la chiamata ricorsiva deve essere l'ultima operazione della funzione, quindi dopo questa chiamata ricorsiva, la funzione corrente terminerà e non ci sarà alcuna "riga successiva di codice da eseguire" da ricordare.

  2. non ci devono essere operazioni coinvolte con questa chiamata ricorsiva. Nessuna operazione matematica, qualunque sia, soprattutto se coinvolge una variabile locale. Deve semplicemente return f(...) e restituirlo da solo . (o chiama questa procedura da solo). Il ... interno può comportare calcoli, anche se coinvolge variabili locali. Al di fuori di (...) non puoi fare alcun calcolo.

posta 太極者無極而生 10.01.2016 - 18:07
fonte

3 risposte

6

Considerare il linguaggio funzionale Clojure , poiché il TCO è difficile da implementare nella JVM, Clojure presenta le chiamate tail come un'espressione con recur . Ad esempio, potremmo avere la seguente funzione fattoriale .

(def factorial
  (fn [n]
    (let [factorial-inner 
         (fn [cnt acc]
           (if (zero? cnt)
             acc
             (recur (dec cnt) (* acc cnt))))]
         (factorial-inner n 1))))

(In genere la macro loop viene utilizzata come metodo per salvare funzioni di definizione come factorial-inner .) Ciò è funzionalmente equivalente al seguente

(def factorial
   (fn [n]
     (if (zero? n)
         1
         (* n (factorial (dec n))))))

Tuttavia, recur può essere chiamato solo nella posizione di coda e quindi garantisce che il TCO possa essere eseguito. È legato essenzialmente alle regole che elencherai. Ciò che aggiunge complessità a questa risposta è che ci sono una serie di ottimizzazioni ben note che trasformano le chiamate ricorsive in chiamate tail in modo da poter eseguire il TCO. Un po 'di confusione, queste ottimizzazioni sono talvolta chiamate anche TCO.

Un esempio ben noto è Modulo di risposta coda . Una funzione scritta in questo modo ha la forma:

f (base_case) = base_value
f (args) = cons(g(args), f(h(args)))  

Le funzioni scritte in questo modo, quando cons e g soddisfano le condizioni appropriate, possono essere riscritte come

f (args) = f_inner(args, base_value)
  where
    f_inner (base_case, acc) = acc
    f_inner (args, acc) = f_inner(h(args), cons(g(args), acc))

dove f_inner è chiaramente ricorsiva in coda. (Esercizio: mostra che la definizione di non-coda chiamata factorial è una coda chiamata modulo cons e che può essere trasformata nella prima definizione.) Casi ancora più complicati di tali ottimizzazioni di riscrittura (quelle che riscrivono una chiamata non a coda -la definizione ricorsiva in uno ricorsivo coda-chiamata) sono possibili. L'implementazione GHC del linguaggio Haskell contiene forse l'uso più ampio delle ottimizzazioni per la riscrittura dei termini (anche consentendo agli utenti di contribuire con le regole di riscrittura). Per ulteriori dettagli, consulta questa pagina e i documenti collegati.

    
risposta data 10.01.2016 - 19:10
fonte
3

Una coda è una chiamata che viene eseguita immediatamente prima che la funzione chiamante ritorni. In return n + foo() , l'ultima operazione prima di tornare deve essere l'aggiunta, non la chiamata. In return foo() , la chiamata è effettivamente in posizione di coda.

Perché un compilatore non può ottimizzarlo magicamente, poiché alcune semplici operazioni non influiscono sullo stack? Poiché nella maggior parte delle convenzioni di chiamata, una funzione riceve un indirizzo di ritorno durante la chiamata. Un return è come un goto a questo indirizzo. Quando una funzione a() { return b() }; a() non è ottimizzata per la coda, otteniamo una sequenza di operazioni come questa:

  • a() è chiamato con un indirizzo di ritorno nel principale
  • b() è chiamato con un indirizzo di ritorno in a
  • b ritorna in a
  • a ritorna nel principale

Con una ricorsione profonda, tutti questi indirizzi di ritorno iniziano a consumare molto spazio nello stack. Quindi con TCO, una chiamata di coda è data l'indirizzo di ritorno della funzione corrente:

  • a() è chiamato con indirizzo di ritorno nel principale
  • b() è chiamato con indirizzo di ritorno nel ← ← TCO!
  • principale
  • b ritorna nel principale

Quindi dopo che b è stato chiamato come coda, il flusso di controllo non è mai rientrato nella funzione a ! E poiché lo stesso indirizzo di ritorno viene riutilizzato per la chiamata, non è stato utilizzato alcuno spazio aggiuntivo.

Le tue regole finali sono quindi sufficientemente corrette. Tuttavia, return n * fact(n - 1) ha un'operazione in coda! Questa è la moltiplicazione * , che sarà l'ultima cosa che fa la funzione prima che ritorni. In alcune lingue, questo potrebbe effettivamente essere implementato come una chiamata di funzione che potrebbe quindi essere ottimizzata in coda.

Un altro punto da tenere a mente è che le chiamate tail sono un caso speciale di "chiamata con continuazione". Una continuazione è come una funzione o una chiusura, ma ricorda anche dove in quella funzione l'esecuzione è stata interrotta - può quindi essere continuata. Un aspetto sorprendente è che ogni programma può essere tradotto in una forma che utilizza le continuazioni, dove ogni chiamata è una coda! Questo è chiamato Continuation-Passing-Style e viene usato in alcuni compilatori come forma intermedia.

    
risposta data 10.01.2016 - 19:29
fonte
1

L'ottimizzazione delle chiamate di coda si riduce effettivamente a quelle due ultime regole che hai dichiarato:

La coda chiamata deve essere l'ultima operazione nella funzione e il suo risultato (se presente) deve essere utilizzato solo per restituirlo invariato al chiamante della funzione.

    
risposta data 10.01.2016 - 18:29
fonte

Leggi altre domande sui tag