Perché è più facile ragionare sulla programmazione di linguaggi e programmi che non hanno effetti collaterali?

7

Ho letto "Il perché di Y" di Richard P. Gabriel . È un articolo di facile lettura sul combinatore Y, che è piuttosto raro. L'articolo inizia con la definizione ricorsiva della funzione fattoriale:

(letrec ((f (lambda (n)
              (if (< n 2) 1 (* n (f (- n 1)))))))
  (f 10))

E spiega che letrec può essere definito con un effetto collaterale:

(let ((f #f))
  (set! f (lambda (n)
            (if (< n 2) 1 (* n (f (- n 1))))))
  (f 10))

E il resto dell'articolo descrive che è anche possibile definire letrec con il combinatore Y:

(define (Y f)
  (let ((g (lambda (h)
             (lambda (x)
               ((f (h h)) x)))))
    (g g)))

(let ((f (Y (lambda (fact)
              (lambda (n)
                (if (< n 2) 1 (* n (fact (- n 1)))))))))
  (f 10))

Ovviamente questo è molto più complicato rispetto alla versione con l'effetto collaterale. Il motivo per cui è vantaggioso preferire il combinatore Y rispetto all'effetto collaterale è dato solo dalla frase:

It is easier to reason about programming languages and programs that have no side effects.

Questo non è spiegato ulteriormente. Provo a trovare una spiegazione.

    
posta ceving 13.12.2016 - 18:16
fonte

6 risposte

12

Ovviamente, puoi trovare esempi di funzioni pure estremamente difficili da leggere che eseguono gli stessi calcoli delle funzioni con effetti collaterali che sono molto più facili da leggere. Soprattutto quando si usa una trasformazione meccanica come un combinatore a Y per arrivare a una soluzione. Questo non è ciò che si intende per "più facile ragionare".

Il motivo per cui è più facile ragionare sulle funzioni senza effetti collaterali è che devi solo preoccuparti degli input e degli output. Con gli effetti collaterali, devi anche preoccuparti di quante volte vengono chiamate le funzioni, in che ordine vengono chiamati, quali dati vengono creati all'interno della funzione, quali dati sono condivisi e quali dati vengono copiati. E tutte queste informazioni per qualsiasi funzione che possa essere chiamata interna alla funzione che stai chiamando, e ricorsivamente interna a tali funzioni, e così via.

Questo effetto è molto più facile da vedere nel codice di produzione con diversi livelli rispetto alle funzioni di esempio del giocattolo. Principalmente significa che puoi contare molto di più solo sulla firma del tipo di una funzione. Noti davvero il peso degli effetti collaterali se esegui una pura programmazione funzionale per un po 'e poi ritorni ad esso.

    
risposta data 21.01.2017 - 07:29
fonte
10

Una proprietà interessante delle lingue senza effetti collaterali è che l'introduzione di parallelismo, concorrenza o asincronia non può cambiare il significato del programma. Può renderlo più veloce. O può renderlo più lento. Ma non può essere sbagliato.

Ciò rende banale la parallelizzazione automatica dei programmi. Così banale, infatti, che di solito finisci con troppo parallelismo! Il team di GHC ha sperimentato la parallelizzazione automatica. Hanno scoperto che anche i programmi semplici potevano essere scomposti in centinaia, persino migliaia di thread. Il sovraccarico di tutti questi thread supererà ogni potenziale aumento di diversi ordini di grandezza.

Quindi, per la parallelizzazione automatica dei programmi funzionali, il problema diventa "come si raggruppano piccole operazioni atomiche in utili dimensioni di pezzi paralleli", al contrario di programmi impuri, dove il problema è "come si fa a scomporre grandi monolitici? operazioni in dimensioni utili di pezzi paralleli ". La cosa bella di questo è che il primo può essere fatto euristicamente (ricorda: se ti sbagli, la cosa peggiore che può succedere è che il programma gira leggermente più lentamente di quanto potrebbe essere), mentre il secondo equivale a risolvere il problema. Problema (nel caso generale), e se sbagli, il tuo programma andrà semplicemente in crash (se sei fortunato!) O restituirà risultati leggermente sbagliati (nel peggiore dei casi).

    
risposta data 14.12.2016 - 00:04
fonte
6

Le lingue con effetti collaterali utilizzano analisi aliasing per vedere se una posizione di memoria potrebbe aver bisogno di essere ricaricato dopo una chiamata di funzione. Quanto è conservativa questa analisi dipende dalla lingua.

Per C, questo deve essere piuttosto prudente, in quanto la lingua non è sicura.

Per Java e C # questi non devono essere conservativi a causa della maggiore sicurezza del tipo.

Essere eccessivamente prudenti impedisce ottimizzazioni del carico.

Tale analisi non sarebbe necessaria (o banale a seconda di come la si guarda) in una lingua senza effetti collaterali.

    
risposta data 13.12.2016 - 19:51
fonte
4

Esistono sempre ottimizzazioni per trarre vantaggio da qualsiasi ipotesi tu fornisca. Mi vengono in mente le operazioni di riordino.

Un esempio divertente che viene in mente si presenta in alcune vecchie lingue di assemblaggio. In particolare, il MIPS aveva una regola che eseguiva l'istruzione dopo un salto condizionato, indipendentemente da quale ramo fosse stato preso. Se non lo volevi, metti un NOP dopo il salto. Ciò è stato fatto a causa del modo in cui la pipeline MIPS è stata strutturata. C'era uno stallo naturale a 1 ciclo incorporato nell'esecuzione del salto condizionale, quindi potresti anche fare qualcosa di utile con quel ciclo!

I compilatori spesso cercano un'operazione che deve essere eseguita su entrambi i rami e inserirla in quello slot, per ottenere un po 'più di prestazioni. Tuttavia, se un compilatore non può farlo, ma può dimostrare che non ci sono effetti collaterali per l'operazione, il compilatore potrebbe opportunisticamente bloccarlo in quel punto. Pertanto, su un percorso, il codice eseguirà un'istruzione più velocemente. Sull'altro percorso, nessun danno fatto.

    
risposta data 13.12.2016 - 22:50
fonte
1

"letrec può essere definito con un effetto collaterale ..." Non vedo alcun effetto collaterale nella tua definizione. Sì, usa set! che è un tipico modo di produrre effetti collaterali in Scheme, ma in questo caso non c'è alcun effetto collaterale - perché f è puramente locale, non può essere referenziato da qualsiasi funzione diversa da localmente. Non è quindi un effetto collaterale visto da qualsiasi ambito esterno. Ciò che questo codice fa fa è incidere su una limitazione nell'oscillazione di Scheme che, per impostazione predefinita, non consente a una definizione lambda di riferirsi a se stessa.

Alcuni altri linguaggi hanno dichiarazioni in cui un'espressione usata per produrre il valore di una costante può riferirsi alla costante stessa. In un tale linguaggio, è possibile utilizzare l'esatta definizione equivalente, ma chiaramente questo non produce un effetto collaterale, poiché viene utilizzata solo una costante. Vedi, ad esempio, questo programma Haskell equivalente:

let f = \ n -> if n < 2 
                 then 1 
                 else n*(f (n-1)) 
        in (f 5)

(che valuta a 120).

Questo chiaramente non ha effetti collaterali (come nel caso in cui una funzione in Haskell abbia un effetto collaterale, deve restituire il suo risultato racchiuso in una Monade, ma il tipo restituito qui è un semplice tipo numerico), ma è strutturalmente identico codice per il codice che citi.

    
risposta data 15.12.2016 - 17:20
fonte
0

This is not explained any further. I try to find an explanation.

È qualcosa che è intrinseco a molti di noi che hanno eseguito il debug di codebase enormi, ma per poterlo apprezzare devi affrontare una scala abbastanza grande a livello di supervisore. È come capire l'importanza di essere in posizione in Poker. Inizialmente non sembra un vantaggio così utile andare alla fine di ogni turno fino a quando non registri una storia di mani di un milione di mani e ti rendi conto che hai vinto così tanto denaro in posizione che fuori.

Detto questo, non sono d'accordo con l'idea che il passaggio a una variabile locale sia un effetto collaterale. Dal mio punto di vista, una funzione non causa effetti collaterali se non modifica nulla al di fuori del suo ambito, che qualsiasi cosa tocchi e manomette non influirà su qualcosa sotto lo stack di chiamate o su qualsiasi memoria o risorsa che la funzione non ha acquisito .

In generale, la cosa più difficile da ragionare in una base di codice complessa e su larga scala che non ha la procedura di test più perfetta che si possa immaginare è la gestione persistente dello stato, come tutte le modifiche agli oggetti granulari in un mondo di videogiochi mentre guadagni attraverso decine di migliaia di funzioni mentre cercavo di restringere una lista infinita di sospetti che in realtà causava la violazione di un invariante a livello di sistema ("questo non dovrebbe mai accadere, chi lo ha fatto?"). Se nulla viene mai modificato al di fuori di una funzione, allora non può causare un malfunzionamento centrale.

Ovviamente non è possibile farlo in tutti i casi. Qualsiasi applicazione che aggiorna un database memorizzato su un altro computer è, per sua natura, progettata per causare effetti collaterali, nonché qualsiasi applicazione progettata per caricare e scrivere file. Ma c'è molto di più che possiamo fare senza effetti collaterali in molte funzioni e molti programmi se, ad esempio, disponessimo di una ricca libreria di strutture dati immutabili e avessimo adottato ulteriormente questa mentalità.

Stranamente John Carmack è saltato sul LISP e sul carrozzone dell'immutabilità nonostante abbia iniziato nei giorni della codifica C più sintonizzata. Mi sono trovato a fare una cosa simile, anche se uso ancora molto C. Tale è la natura dei pragmatici, penso, che hanno trascorso anni di debugging e tentando di ragionare su sistemi complessi e su larga scala nel loro insieme da un livello di sovrintendente. Persino quelli che sono sorprendentemente robusti e privi di una grande quantità di bug possono ancora lasciarti con una sensazione inquietante che qualcosa di sbagliato è in agguato dietro l'angolo se c'è un sacco di complessi stati persistenti che vengono modificati tra il più complesso grafico interconnesso delle chiamate di funzione tra i milioni di righe di codice. Anche se ogni singola interfaccia viene testata con un test unitario e tutto passa, c'è anche la sensazione spiacevole di ciò che potrebbe accadere agli stati centrali con un caso di input non previsto con tutte le innumerevoli chiamate di funzioni interdipendenti tra interfacce se la logica dell'applicazione ruota attorno al lato a cascata effetti agli stati più centrali e persistenti.

In pratica, trovo spesso che la programmazione funzionale renda più difficile comprendere una funzione. Gira ancora il mio cervello in colpi di scena e nodi, specialmente con una complessa logica ricorsiva. Ma tutto il sovraccarico intellettuale associato a capire un paio di funzioni scritte in un linguaggio funzionale è sminuito da quello di un sistema complesso con stati persistenti che vengono cambiati attraverso decine di migliaia di funzioni, dove ogni funzione che causa effetti collaterali si somma al totale complessità del ragionamento sulla correttezza dell'intero sistema nel suo complesso.

Nota che non hai bisogno di un linguaggio puramente funzionale per far sì che le funzioni evitino gli effetti collaterali. Gli stati locali modificati in una funzione non contano come effetto collaterale, come una variabile del contatore del ciclo for locale a una funzione non conta come un effetto collaterale. Scrivo anche codice C al giorno d'oggi con l'obiettivo di evitare gli effetti collaterali quando possibile e ho escogitato una libreria di strutture di dati immutabili e thread-safe che possono essere parzialmente modificate mentre il resto dei dati è poco profondo, e mi ha aiutato ottimo per ragionare sulla correttezza del mio sistema. Sono strongmente in disaccordo con l'autore in questo senso. Almeno in C e C ++ in software mission-critical, una funzione può documentare come senza effetti collaterali se non tocca nulla che possa influenzare il mondo al di fuori della funzione.

    
risposta data 01.12.2017 - 15:20
fonte

Leggi altre domande sui tag