Idee sbagliate su linguaggi puramente funzionali?


39

Incontro spesso le seguenti dichiarazioni / argomenti:

  1. I linguaggi di programmazione funzionale pura non consentono effetti collaterali (e sono quindi di scarsa utilità nella pratica perché qualsiasi programma utile ha effetti collaterali, ad esempio quando interagisce con il mondo esterno).
  2. I linguaggi di programmazione funzionale pura non consentono di scrivere un programma che mantenga lo stato (il che rende la programmazione molto imbarazzante perché in molte applicazioni è necessario lo stato).

Non sono un esperto di linguaggi funzionali, ma ecco quello che ho capito su questi argomenti fino ad ora.

Per quanto riguarda il punto 1, è possibile interagire con l'ambiente in linguaggi puramente funzionali, ma è necessario contrassegnare esplicitamente il codice (funzioni) che introduce effetti collaterali (ad esempio in Haskell per mezzo di tipi monadici). Inoltre, per quanto ne so, dovrebbe essere possibile anche il calcolo mediante effetti collaterali (aggiornamento distruttivo dei dati) (usando tipi monadici?) Anche se non è il modo di lavorare preferito.

Per quanto riguarda il punto 2, per quanto ne so, è possibile rappresentare lo stato facendo passare i valori attraverso diversi passaggi di calcolo (in Haskell, di nuovo, usando tipi monadici) ma non ho esperienza pratica nel fare questo e la mia comprensione è piuttosto vaga.

Quindi, le due affermazioni sopra riportate sono corrette in ogni senso o sono solo idee sbagliate su linguaggi puramente funzionali? Se sono idee sbagliate, come sono nate? Potresti scrivere uno snippet di codice (possibilmente piccolo) che illustri il modo idiomatico di Haskell per (1) implementare gli effetti collaterali e (2) implementare un calcolo con stato?


7
Penso che la maggior parte di ciò dipenda da ciò che definisci un linguaggio funzionale "puro".
jk.

@jk: per evitare il problema di definire linguaggi funzionali 'puri', assumere la purezza nel senso di Haskell (che è ben definito). A quali condizioni un linguaggio funzionale può essere considerato "puro" può essere l'argomento di una domanda futura.
Giorgio,

Entrambe le risposte contengono molte idee chiarificatrici ed è stato difficile per me scegliere quale accettare. Ho deciso di accettare la risposta di sepp2k a causa degli esempi di pseudo-codice aggiuntivi.
Giorgio,

Risposte:


26

Ai fini di questa risposta, definisco "linguaggio puramente funzionale" per indicare un linguaggio funzionale in cui le funzioni sono referenzialmente trasparenti, vale a dire che chiamare la stessa funzione più volte con gli stessi argomenti produrrà sempre gli stessi risultati. Questa è, credo, la solita definizione di un linguaggio puramente funzionale.

I linguaggi di programmazione funzionale pura non consentono effetti collaterali (e sono quindi di scarsa utilità nella pratica perché qualsiasi programma utile ha effetti collaterali, ad esempio quando interagisce con il mondo esterno).

Il modo più semplice per ottenere la trasparenza referenziale sarebbe in effetti vietare gli effetti collaterali e ci sono davvero lingue in cui questo è il caso (per lo più quelli specifici di dominio). Tuttavia non è certamente l'unico modo e i linguaggi puramente funzionali più generici (Haskell, Clean, ...) consentono effetti collaterali.

Dire anche che un linguaggio di programmazione senza effetti collaterali è poco utile in pratica non è davvero giusto, penso, certamente non per linguaggi specifici di dominio, ma anche per linguaggi di uso generale, immagino che un linguaggio possa essere abbastanza utile senza fornire effetti collaterali . Forse non per le applicazioni console, ma penso che le applicazioni GUI possano essere ben implementate senza effetti collaterali, ad esempio nel paradigma reattivo funzionale.

Per quanto riguarda il punto 1, è possibile interagire con l'ambiente in linguaggi puramente funzionali, ma è necessario contrassegnare esplicitamente il codice (funzioni) che li introduce (ad esempio in Haskell per mezzo di tipi monadici).

Questo è un po 'troppo per semplificarlo. Il semplice fatto di disporre di un sistema in cui le funzioni con effetti collaterali devono essere contrassegnate come tali (simile alla correttezza const in C ++, ma con effetti collaterali generali) non è sufficiente per garantire la trasparenza referenziale. È necessario assicurarsi che un programma non possa mai chiamare una funzione più volte con gli stessi argomenti e ottenere risultati diversi. Puoi farlo facendo cose del generereadLineessere qualcosa che non è una funzione (è quello che Haskell fa con la monade IO) o potresti rendere impossibile chiamare più volte le funzioni con effetti collaterali con lo stesso argomento (ecco cosa fa Clean). In quest'ultimo caso, il compilatore assicurerebbe che ogni volta che chiamate una funzione con effetti collaterali, lo facciate con un nuovo argomento, e respingerebbe qualsiasi programma in cui passate lo stesso argomento a una funzione con effetti collaterali due volte.

I linguaggi di programmazione funzionale pura non consentono di scrivere un programma che mantenga lo stato (il che rende la programmazione molto imbarazzante perché in molte applicazioni è necessario lo stato).

Ancora una volta, un linguaggio puramente funzionale potrebbe benissimo impedire lo stato mutabile, ma è certamente possibile essere puro e avere ancora uno stato mutabile, se lo si implementa nello stesso modo in cui ho descritto con gli effetti collaterali sopra. Lo stato realmente mutabile è solo un'altra forma di effetti collaterali.

Detto questo, i linguaggi di programmazione funzionale scoraggiano sicuramente lo stato mutevole, specialmente quelli puri. E non penso che ciò renda la programmazione imbarazzante, al contrario. A volte (ma non tanto spesso) lo stato mutabile non può essere evitato senza perdere prestazioni o chiarezza (motivo per cui lingue come Haskell hanno strutture per lo stato mutevole), ma molto spesso può.

Se sono idee sbagliate, come sono nate?

Penso che molte persone leggano semplicemente "una funzione deve produrre lo stesso risultato quando viene chiamata con gli stessi argomenti" e ne deduco che non è possibile implementare qualcosa di simile readLineo un codice che mantenga uno stato mutabile. Quindi semplicemente non sono consapevoli dei "trucchi" che i linguaggi puramente funzionali possono usare per introdurre queste cose senza rompere la trasparenza referenziale.

Anche lo stato mutevole è fortemente scoraggiante nei linguaggi funzionali, quindi non è affatto un salto dal presupposto che non sia permesso affatto in quelli puramente funzionali.

Potresti scrivere uno snippet di codice (possibilmente piccolo) che illustri il modo idiomatico di Haskell per (1) implementare gli effetti collaterali e (2) implementare un calcolo con stato?

Ecco un'applicazione in Pseudo-Haskell che chiede all'utente un nome e lo saluta. Lo pseudo-Haskell è un linguaggio che ho appena inventato, che ha il sistema IO di Haskell, ma usa una sintassi più convenzionale, nomi di funzioni più descrittivi e non ha donotazione (in quanto ciò distrarrebbe da come funziona esattamente la monade IO):

greet(name) = print("Hello, " ++ name ++ "!")
main = composeMonad(readLine, greet)

L'indizio qui è che readLineè un valore di tipo IO<String>ed composeMonadè una funzione che accetta un argomento di tipo IO<T>(per un certo tipo T) e un altro argomento che è una funzione che accetta un argomento di tipo Te restituisce un valore di tipo IO<U>(per un certo tipo U). printè una funzione che accetta una stringa e restituisce un valore di tipo IO<void>.

Un valore di tipo IO<A>è un valore che "codifica" una determinata azione che produce un valore di tipo A. composeMonad(m, f)produce un nuovo IOvalore che codifica l'azione di mseguito dall'azione di f(x), dove xè il valore prodotto eseguendo l'azione di m.

Lo stato mutevole sarebbe simile al seguente:

counter = mutableVariable(0)
increaseCounter(cnt) =
    setIncreasedValue(oldValue) = setValue(cnt, oldValue + 1)
    composeMonad(getValue(cnt), setIncreasedValue)

printCounter(cnt) = composeMonad( getValue(cnt), print )

main = composeVoidMonad( increaseCounter(counter), printCounter(counter) )

Ecco mutableVariableuna funzione che prende valore di qualsiasi tipo Te produce a MutableVariable<T>. La funzione getValueaccetta MutableVariablee restituisce un valore IO<T>che produce il valore corrente. setValueprende a MutableVariable<T>e a Te restituisce un IO<void>valore che imposta il valore. composeVoidMonadequivale ad composeMonadeccezione del fatto che il primo argomento è un IOche non produce un valore sensibile e il secondo argomento è un'altra monade, non una funzione che restituisce una monade.

In Haskell c'è dello zucchero sintattico, che rende meno doloroso tutto questo calvario, ma è ancora ovvio che lo stato mutevole è qualcosa che la lingua non vuole davvero che tu faccia.


Ottima risposta, chiarendo molte idee. L'ultima riga dello snippet di codice dovrebbe usare il nome counter, ovvero increaseCounter(counter)?
Giorgio,

@Giorgio Sì, dovrebbe. Fisso.
sepp2k,

1
@Giorgio Una cosa che ho dimenticato di menzionare esplicitamente nel mio post è che l'azione IO restituita mainsarà quella che viene effettivamente eseguita. Oltre a restituire un IO da mainlì non c'è modo di eseguire IOazioni (senza usare funzioni orribilmente malvagie che hanno unsafenel loro nome).
sepp2k,

OK. scarfridge menzionava anche IOvalori distruttivi . Non capivo se si riferisse alla corrispondenza dei modelli, cioè al fatto che è possibile decostruire i valori di un tipo di dati algebrico, ma non si può usare la corrispondenza dei modelli per farlo con i IOvalori.
Giorgio,

16

IMHO sei confuso perché c'è una differenza tra un linguaggio puro e una funzione pura . Cominciamo con la funzione. Una funzione è pura se restituisce sempre lo stesso valore (dato lo stesso input) e non provoca effetti collaterali osservabili. Esempi tipici sono funzioni matematiche come f (x) = x * x. Ora considera un'implementazione di questa funzione. Sarebbe puro nella maggior parte delle lingue anche quelle che non sono generalmente considerate linguaggi funzionali puri, ad esempio ML. Anche un metodo Java o C ++ con questo comportamento può essere considerato puro.

Quindi cos'è un linguaggio puro? A rigor di termini, ci si potrebbe aspettare che un linguaggio puro non ti permetta di esprimere funzioni che non sono pure. Chiamiamo questa la definizione idealistica di un linguaggio puro. Tale comportamento è altamente desiderabile. Perché? Bene, la cosa bella di un programma che consiste solo di funzioni pure è che puoi sostituire l'applicazione con il suo valore senza cambiare il significato del programma. Questo rende molto facile ragionare sui programmi perché una volta che conosci il risultato puoi dimenticare il modo in cui è stato calcolato. La purezza potrebbe anche consentire al compilatore di eseguire determinate ottimizzazioni aggressive.

E se avessi bisogno di uno stato interno? È possibile simulare lo stato in un linguaggio puro semplicemente aggiungendo lo stato prima del calcolo come parametro di input e lo stato dopo il calcolo come parte del risultato. Invece di Int -> Boolottenere qualcosa del genere Int -> State -> (Bool, State). Devi semplicemente rendere esplicita la dipendenza (che è considerata una buona pratica in qualsiasi paradigma di programmazione). A proposito c'è una monade che è un modo particolarmente elegante per combinare tali funzioni che imitano lo stato in più grandi funzioni che imitano lo stato. In questo modo puoi sicuramente "mantenere lo stato" in un linguaggio puro. Ma devi renderlo esplicito.

Questo significa che posso interagire con l'esterno? Dopo tutto un programma utile deve interagire con il mondo reale per essere utile. Ma input e output ovviamente non sono puri. Scrivere un byte specifico in un file specifico potrebbe andare bene per la prima volta. Ma eseguire la stessa identica operazione una seconda volta potrebbe restituire un errore perché il disco è pieno. Chiaramente non esiste un linguaggio puro (nel significato idealistico) che possa scrivere su un file.

Quindi siamo di fronte a un dilemma. Vogliamo principalmente funzioni pure ma alcuni effetti collaterali sono assolutamente necessari e quelli non sono puri. Ora una definizione realistica di un linguaggio puro sarebbe che ci devono essere alcuni mezzi per separare le parti pure dalle altre parti. Il meccanismo deve garantire che nessuna operazione impura si insinui nelle parti pure.

In Haskell questo viene fatto con il tipo IO. Non è possibile distruggere un risultato IO (senza meccanismi non sicuri). Pertanto, è possibile elaborare i risultati IO solo con le funzioni definite nel modulo IO stesso. Fortunatamente esiste un combinatore molto flessibile che consente di prendere un risultato IO ed elaborarlo in una funzione purché tale funzione restituisca un altro risultato IO. Questo combinatore si chiama bind (o >>=) e ha il tipo IO a -> (a -> IO b) -> IO b. Se generalizzi questo concetto, arrivi alla classe della monade e IO sembra esserne un'istanza.


4
Non vedo davvero come Haskell (ignorando qualsiasi funzione con unsafenel suo nome) non soddisfi la tua definizione idealistica. Non ci sono funzioni impure in Haskell (ancora ignorando unsafePerformIOe co.).
sepp2k,

4
readFilee writeFilerestituirà sempre lo stesso IOvalore, dati gli stessi argomenti. Ad esempio, i due frammenti di codice let x = writeFile "foo.txt" "bar" in x >> xe writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"faranno la stessa cosa.
sepp2k,

3
@AidanCully Cosa intendi con "Funzione IO"? Una funzione che restituisce un valore di tipo IO Something? In tal caso, è perfettamente possibile chiamare due volte una funzione IO con lo stesso argomento: putStrLn "hello" >> putStrLn "hello"- qui entrambe le chiamate putStrLnhanno lo stesso argomento. Naturalmente questo non è un problema perché, come ho detto prima, entrambe le chiamate daranno lo stesso valore IO.
sepp2k,

3
@scarfridge La valutazione writeFile "foo.txt" "bar"non può causare un errore poiché la valutazione della chiamata di funzione non esegue l'azione. Se stai dicendo che nel mio esempio precedente la versione con letha solo una possibilità di causare un errore di I / O mentre la versione senza ne letha due, ti sbagli. Entrambe le versioni hanno due opportunità per un errore di I / O. Poiché la letversione valuta la chiamata writeFileuna sola volta mentre la versione senza la letvaluta due volte, è possibile notare che non importa quanto spesso viene chiamata la funzione.
Importa

6
@AidanCully Il "meccanismo della monade" non passa attorno ai parametri impliciti. La putStrLnfunzione accetta esattamente un argomento, che è di tipo String. Se non mi credete, guardate il suo tipo: String -> IO (). Certamente non accetta argomenti di tipo IO: produce un valore di quel tipo.
sepp2k,
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.