I linguaggi funzionali sono migliori alla ricorsione?


41

TL; DR: i linguaggi funzionali gestiscono la ricorsione meglio di quelli non funzionali?

Attualmente sto leggendo il codice completo 2. Ad un certo punto del libro, l'autore ci mette in guardia sulla ricorsione. Dice che dovrebbe essere evitato quando possibile e che le funzioni che usano la ricorsione sono generalmente meno efficaci di una soluzione che usa i loop. Ad esempio, l'autore ha scritto una funzione Java usando la ricorsione per calcolare il fattoriale di un numero simile (potrebbe non essere esattamente lo stesso poiché al momento non ho il libro con me):

public int factorial(int x) {
    if (x <= 0)
        return 1;
    else
        return x * factorial(x - 1);
}

Questo è presentato come una cattiva soluzione. Tuttavia, nei linguaggi funzionali, l'uso della ricorsione è spesso il modo preferito di fare le cose. Ad esempio, ecco la funzione fattoriale in Haskell usando la ricorsione:

factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

Ed è ampiamente accettata come una buona soluzione. Come ho visto, Haskell usa la ricorsione molto spesso e non ho visto da nessuna parte che è malvisto.

Quindi la mia domanda in sostanza è:

  • I linguaggi funzionali gestiscono la ricorsione meglio di quelli non funzionali?

EDIT: sono consapevole che gli esempi che ho usato non sono i migliori per illustrare la mia domanda. Volevo solo sottolineare che Haskell (e i linguaggi funzionali in generale) usano la ricorsione molto più spesso dei linguaggi non funzionali.


10
Caso in questione: molti linguaggi funzionali fanno un uso pesante dell'ottimizzazione delle chiamate di coda, mentre pochissimi linguaggi procedurali lo fanno. Ciò significa che la ricorsione della chiamata di coda è molto più economica in quei linguaggi funzionali.
Joachim Sauer,

7
In realtà, la definizione di Haskell che hai dato è piuttosto negativa. factorial n = product [1..n]è più conciso, più efficiente e non riempie troppo lo stack n(e se hai bisogno di memoization, sono necessarie opzioni completamente diverse). productè definito in termini di alcuni fold, che è definito in modo ricorsivo, ma con estrema cura. La ricorsione è una soluzione accettabile per la maggior parte del tempo, ma è comunque facile farlo in modo errato / non ottimale.

1
@JoachimSauer - Con un po 'di abbellimento, il tuo commento darebbe una risposta utile.
Mark Booth,

La tua modifica indica che non hai colto la mia deriva. La definizione che hai dato è un perfetto esempio di ricorsione che è male anche nei linguaggi funzionali . La mia alternativa è anche ricorsiva (anche se è in una funzione di libreria) ed è molto efficiente, solo il modo in cui ricorre fa la differenza. Haskell è anche un caso strano in cui la pigrizia infrange le solite regole (caso esemplificativo: le funzioni possono traboccare lo stack pur essendo ricorsive di coda ed essere molto efficienti senza essere ricorsive di coda).

@delnan: grazie per il chiarimento!
Modificherò la

Risposte:


36

Sì, lo fanno, ma non solo perché possono , ma perché devono farlo .

Il concetto chiave qui è la purezza : una funzione pura è una funzione senza effetti collaterali e senza stato. I linguaggi di programmazione funzionale generalmente abbracciano la purezza per molte ragioni, come il ragionamento sul codice ed evitare dipendenze non ovvie. Alcune lingue, in particolare Haskell, arrivano persino a consentire solo il codice puro; eventuali effetti collaterali che un programma può avere (come l'esecuzione di I / O) vengono spostati in un runtime non puro, mantenendo puro il linguaggio stesso.

Non avere effetti collaterali significa che non puoi avere contatori di loop (perché un contatore di loop costituirebbe uno stato mutabile e la modifica di tale stato sarebbe un effetto collaterale), quindi il più iterativo che un linguaggio funzionale puro può ottenere è quello di scorrere su un elenco ( questa operazione viene in genere chiamata foreacho map). La ricorsione, tuttavia, è una corrispondenza naturale con la pura programmazione funzionale - non è necessario alcuno stato per ricorrere, tranne per gli argomenti della funzione (sola lettura) e un valore restituito (sola scrittura).

Tuttavia, non avere effetti collaterali significa anche che la ricorsione può essere implementata in modo più efficiente e il compilatore può ottimizzarla in modo più aggressivo. Non ho studiato a fondo alcun compilatore del genere, ma per quanto ne so, i compilatori della maggior parte dei linguaggi di programmazione funzionale eseguono l'ottimizzazione delle chiamate di coda, e alcuni potrebbero persino compilare alcuni tipi di costrutti ricorsivi in ​​loop dietro le quinte.


2
Per la cronaca, l'eliminazione della coda non si basa sulla purezza.
scarfridge,

2
@scarfridge: Certo che no. Tuttavia, quando viene data la purezza, è molto più facile per un compilatore riordinare il codice per consentire le chiamate di coda.
tdammers,

GCC svolge un lavoro molto migliore di TCO rispetto a GHC, perché non è possibile eseguire TCO attraverso la creazione di un thunk.
dan_waterworth,

18

Stai confrontando la ricorsione con l'iterazione. Senza l' eliminazione della coda di chiamata , l'iterazione è effettivamente più efficiente perché non esiste una chiamata di funzione aggiuntiva. Inoltre, l'iterazione può continuare all'infinito, mentre è possibile esaurire lo spazio dello stack da troppe chiamate di funzione.

Tuttavia, l'iterazione richiede la modifica di un contatore. Ciò significa che deve esserci una variabile mutabile , che è proibita in un ambiente puramente funzionale. Quindi i linguaggi funzionali sono appositamente progettati per funzionare senza la necessità di iterazione, quindi le chiamate di funzione semplificate.

Ma nessuno di questi indirizzi spiega perché il tuo esempio di codice sia così elegante. Il tuo esempio dimostra una proprietà diversa, che è la corrispondenza del modello . Ecco perché l'esempio Haskell non ha condizionali espliciti. In altre parole, non è la ricorsione ottimizzata che rende il codice piccolo; è la corrispondenza del modello.


So già di cosa si tratta la corrispondenza dei motivi e penso che sia una caratteristica fantastica in Haskell che mi manca nelle lingue che uso!
marco-fiset,

@marcof Il mio punto è che tutto il discorso sulla ricorsione contro l'iterazione non affronta l'eleganza del tuo esempio di codice. Si tratta davvero della corrispondenza dei modelli rispetto ai condizionali. Forse avrei dovuto metterlo in cima alla mia risposta.
chrisaycock,

Sì, l'ho capito anche io: P
marco-fiset,

@chrisaycock: Sarebbe possibile vedere l'iterazione come ricorsione della coda in cui tutte le variabili utilizzate nel corpo del ciclo sono sia argomenti che valori di ritorno delle chiamate ricorsive?
Giorgio,

@Giorgio: Sì, fai in modo che la tua funzione prenda e restituisca una tupla dello stesso tipo.
Ericson2314,

5

Tecnicamente no, ma praticamente sì.

La ricorsione è molto più comune quando si sta adottando un approccio funzionale al problema. Pertanto, i linguaggi progettati per utilizzare un approccio funzionale spesso includono funzionalità che rendono la ricorsione più facile / migliore / meno problematica. Dalla parte superiore della mia testa, ci sono tre comuni:

  1. Ottimizzazione delle chiamate di coda. Come sottolineato da altri poster, i linguaggi funzionali spesso richiedono il TCO.

  2. Valutazione pigra. Haskell (e poche altre lingue) viene valutato pigramente. Ciò ritarda l'effettivo "lavoro" di un metodo fino a quando non è necessario. Ciò tende a condurre a strutture di dati più ricorsive e, per estensione, a metodi ricorsivi per lavorare su di esse.

  3. Immutabilità. La maggior parte delle cose con cui lavori nei linguaggi di programmazione funzionale è immutabile. Questo rende la ricorsione più facile perché non devi preoccuparti dello stato degli oggetti nel tempo. Ad esempio, non puoi cambiare un valore da sotto di te. Inoltre, molte lingue sono progettate per rilevare funzioni pure . Poiché le funzioni pure non hanno effetti collaterali, il compilatore ha molta più libertà su quale ordine eseguono le funzioni e altre ottimizzazioni.

Nessuna di queste cose è davvero specifica per i linguaggi funzionali rispetto ad altri, quindi non sono semplicemente migliori perché sono funzionali. Ma poiché sono funzionali, le decisioni di progettazione prese tenderanno verso queste caratteristiche perché sono più utili (e i loro lati negativi meno problematici) durante la programmazione funzionale.


1
Ri: 1. I ritorni anticipati non hanno nulla a che fare con le chiamate di coda. È possibile tornare presto con una chiamata di coda e avere il ritorno "in ritardo" anche con una chiamata di coda, e si può avere un'unica espressione semplice con la chiamata ricorsiva non in una posizione di coda (cfr. Definizione fattoriale di OP).

@delnan: grazie; è presto ed è passato un bel po 'di tempo da quando ho studiato la cosa.
Telastyn,

1

Haskell e altri linguaggi funzionali generalmente usano una valutazione pigra. Questa funzione consente di scrivere funzioni ricorsive infinite.

Se si scrive una funzione ricorsiva senza definire un caso base in cui termina la ricorsione, si finisce per avere infinite chiamate a quella funzione e stackoverflow.

Haskell supporta anche l'ottimizzazione delle chiamate di funzioni ricorsive. In Java ogni chiamata di funzione si accumulerebbe e causerebbe un sovraccarico.

Quindi sì, i linguaggi funzionali gestiscono la ricorsione meglio di altri.


5
Haskell è tra le pochissime lingue non rigide - l'intera famiglia ML (a parte alcuni spin-off di ricerca che aggiungono pigrizia), tutti i popolari Lisps, Erlang, ecc. Sono tutti rigorosi. Inoltre, il secondo comma sembra fuori - come dichiarate correttamente nel primo paragrafo, la pigrizia non permette ricorsione infinita (il preludio Haskell ha il estremamente utile forever a = a >> forever a, per esempio).

@deinan: Per quanto ne so SML / NJ offre anche una valutazione pigra ma è un'aggiunta a SML. Volevo anche nominare due dei pochi linguaggi funzionali pigri: Miranda e Clean.
Giorgio,

1

L'unica ragione tecnica di cui sono a conoscenza è che alcuni linguaggi funzionali (e alcuni linguaggi imperativi, se ricordo bene) hanno quello che viene chiamato ottimizzazione delle chiamate di coda che consente a un metodo ricorsivo di non aumentare le dimensioni dello stack ad ogni chiamata ricorsiva (cioè la chiamata ricorsiva più o meno sostituisce la chiamata corrente nello stack).

Si noti che questa ottimizzazione non funziona su nessuna chiamata ricorsiva, ma solo metodi ricorsivi di coda (cioè metodi che non mantengono lo stato al momento della chiamata ricorsiva)


1
(1) Tale ottimizzazione si applica solo in casi molto specifici: l'esempio di OP non è loro e molte altre funzioni semplici richiedono un'attenzione particolare per diventare ricorsive. (2) L' ottimizzazione della coda di chiamata reale non solo ottimizza le funzioni ricorsive, ma rimuove l'overhead dello spazio da qualsiasi chiamata immediatamente seguita da un ritorno.

@delnan: (1) Sì, è vero. Nella mia "bozza originale" di questa risposta, avevo detto che :( (2) Sì, ma nel contesto della domanda, ho pensato che sarebbe stato estraneo menzionarlo.
Steven Evers,

Sì, (2) è solo un'utile aggiunta (sebbene indispensabile per lo stile di passaggio di continuazione), le risposte non devono essere menzionate.

1

Avrai voglia di guardare Garbage Collection è veloce, ma una pila è più veloce , un documento sull'uso di ciò che i programmatori C considererebbero "heap" per i frame di stack nel C. compilato. Credo che l'autore abbia armeggiato con Gcc per farlo . Non è una risposta definitiva, ma ciò potrebbe aiutarti a capire alcuni dei problemi con la ricorsione.

Il linguaggio di programmazione Alef , che in passato era associato al Plan 9 di Bell Labs, aveva una dichiarazione "diventare" (Vedi sezione 6.6.4 di questo riferimento ). È una sorta di ottimizzazione esplicita della ricorsione di chiamata in coda. Il "ma utilizza lo stack di chiamate!" l'argomento contro la ricorsione potrebbe potenzialmente essere eliminato.


0

TL; DR: Sì, la
ricorsione è uno strumento chiave nella programmazione funzionale e quindi è stato fatto molto lavoro per ottimizzare queste chiamate. Ad esempio, R5RS richiede (nelle specifiche!) Che tutte le implementazioni gestiscano le chiamate di ricorsione della coda non associate senza che il programmatore si preoccupi dello overflow dello stack. Per fare un confronto, per impostazione predefinita il compilatore C non eseguirà nemmeno un'ovvia ottimizzazione di coda (prova un inverso ricorsivo di un elenco collegato) e dopo alcune chiamate, il programma verrà terminato (Il compilatore ottimizzerà, tuttavia, se usi - O2).

Naturalmente, in programmi scritti in modo orribile, come il famoso fibesempio che è esponenziale, il compilatore non ha quasi nessuna opzione per fare la sua "magia". Quindi bisogna fare attenzione a non ostacolare gli sforzi del compilatore nell'ottimizzazione.

EDIT: Con l'esempio fib, intendo quanto segue:

(define (fib n)
 (if (< n 3) 1 
  (+ (fib (- n 1)) (fib (- n 2)))
 )
)

0

I linguaggi funzionali sono migliori in due tipi molto specifici di ricorsione: ricorsione della coda e ricorsione infinita. Sono cattivi quanto altre lingue in altri tipi di ricorsione, come il tuo factorialesempio.

Questo non vuol dire che non ci sono algoritmi che funzionano bene con la ricorsione regolare in entrambi i paradigmi. Ad esempio, tutto ciò che richiede comunque una struttura di dati simile a uno stack, come una ricerca ad albero in profondità, è più semplice da implementare con la ricorsione.

La ricorsione si presenta più spesso con la programmazione funzionale, ma è anche molto abusata, soprattutto dai principianti o nei tutorial per principianti, forse perché la maggior parte dei principianti alla programmazione funzionale hanno già usato la ricorsione nella programmazione imperativa. Esistono altri costrutti di programmazione funzionale, come la comprensione degli elenchi, le funzioni di ordine superiore e altre operazioni sulle collezioni, che di solito sono molto più adatte concettualmente, allo stile, alla concisione, all'efficienza e alla capacità di ottimizzare.

Ad esempio, il suggerimento di Delnan factorial n = product [1..n]non è solo più conciso e più facile da leggere, ma è anche altamente parallelizzabile. Lo stesso vale per l'utilizzo di foldo reducese la tua lingua non ha productgià un built-in. La ricorsione è la soluzione di ultima istanza per questo problema. Il motivo principale per cui lo vedi risolto in modo ricorsivo nei tutorial è come punto di partenza prima di arrivare a soluzioni migliori, non come esempio di una migliore pratica.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.