Effetti collaterali che rompono la trasparenza referenziale


11

La programmazione funzionale in Scala spiega l'impatto di un effetto collaterale sulla violazione della trasparenza referenziale:

effetto collaterale, che implica una violazione della trasparenza referenziale.

Ho letto parte di SICP , che discute sull'uso del "modello di sostituzione" per valutare un programma.

Dato che capisco approssimativamente il modello di sostituzione con trasparenza referenziale (RT), è possibile decomporre una funzione nelle sue parti più semplici. Se l'espressione è RT, puoi decomporre l'espressione e ottenere sempre lo stesso risultato.

Tuttavia, come afferma la citazione sopra, l'uso degli effetti collaterali può / romperà il modello di sostituzione.

Esempio:

val x = foo(50) + bar(10)

Se fooe bar non hanno effetti collaterali, l'esecuzione di entrambe le funzioni restituirà sempre lo stesso risultato x. Ma, se hanno effetti collaterali, alterano una variabile che interrompe / getta una chiave inglese nel modello di sostituzione.

Mi sento a mio agio con questa spiegazione, ma non la scrivo completamente.

Per favore correggimi e riempi tutti i buchi per quanto riguarda gli effetti collaterali che rompono RT, discutendo anche degli effetti sul modello di sostituzione.

Risposte:


20

Cominciamo con una definizione di trasparenza referenziale :

Si dice che un'espressione è referenzialmente trasparente se può essere sostituita con il suo valore senza cambiare il comportamento di un programma (in altre parole, produrre un programma che ha gli stessi effetti e output sullo stesso input).

Ciò significa che (ad esempio) è possibile sostituire 2 + 5 con 7 in qualsiasi parte del programma e il programma dovrebbe comunque funzionare. Questo processo si chiama sostituzione. La sostituzione è valida se, e solo se, 2 + 5 può essere sostituito con 7 senza influire su nessun'altra parte del programma .

Diciamo che ho una classe chiamata Baz, con le funzioni Fooe Bardentro. Per semplicità, lo diremo semplicemente Fooed Barentrambi restituiranno il valore che viene passato. Quindi Foo(2) + Bar(5) == 7, come ci si aspetterebbe. La trasparenza referenziale garantisce che è possibile sostituire l'espressione Foo(2) + Bar(5)con l'espressione in 7qualsiasi punto del programma e il programma funzionerà comunque in modo identico.

Ma cosa succede se Foorestituisce il valore passato, ma Barrestituisce il valore passato, più l'ultimo valore fornito a Foo? È abbastanza facile da fare se memorizzi il valore di Foouna variabile locale all'interno della Bazclasse. Bene, se il valore iniziale di quella variabile locale è 0, l'espressione Foo(2) + Bar(5)restituirà il valore atteso 7la prima volta che lo invochi, ma restituirà 9la seconda volta che lo invochi.

Ciò viola la trasparenza referenziale in due modi. Innanzitutto, non è possibile contare su Bar per restituire la stessa espressione ogni volta che viene chiamata. In secondo luogo, si è verificato un effetto collaterale, vale a dire che chiamare Foo influenza il valore di ritorno di Bar. Dal momento che non puoi più garantire che Foo(2) + Bar(5)sarà uguale a 7, non puoi più sostituire.

Questo è in pratica la trasparenza referenziale; una funzione referenzialmente trasparente accetta un certo valore e restituisce un valore corrispondente, senza influire su altro codice altrove nel programma e restituisce sempre lo stesso output dato lo stesso input.


5
Quindi rompere RTti impedisce di usare substitution model.Il grosso problema di non poterlo usare substitution modelè il potere di usarlo per ragionare su un programma?
Kevin Meredith,

Esatto.
Robert Harvey,

1
+1 risposta meravigliosamente chiara e comprensibile. Grazie.
Racheet,

2
Inoltre, se tali funzioni sono trasparenti o "pure", l'ordine in cui sono effettivamente eseguite non è importante, non ci importa se foo () o bar () viene eseguito per primo, e in alcuni casi potrebbero non valutare mai se non sono necessarie
Zaccaria K,

1
Un altro vantaggio di RT è la possibilità di memorizzare nella cache espressioni costose e trasparenti dal punto di vista referenziale (poiché valutarle una o due volte dovrebbe produrre esattamente lo stesso risultato).
dcastro,

3

Immagina che stai cercando di costruire un muro e ti è stato dato un assortimento di scatole di diverse dimensioni e forme. È necessario riempire un particolare foro a forma di L nel muro; dovresti cercare una scatola a forma di L o puoi sostituire due scatole diritte delle dimensioni appropriate?

Nel mondo funzionale, la risposta è che entrambe le soluzioni funzioneranno. Quando costruisci il tuo mondo funzionale, non devi mai aprire le scatole per vedere cosa c'è dentro.

Nel mondo imperativo, è pericoloso costruire il tuo muro senza ispezionare il contenuto di ogni scatola e confrontandolo con il contenuto di ogni altra scatola:

  • Alcuni contengono potenti magneti e spingono altre scatole magnetiche fuori dal muro se allineati in modo errato.
  • Alcuni sono molto caldi o freddi e reagiranno male se posizionati in spazi adiacenti.

Penso che mi fermerò prima di perdere tempo con metafore più improbabili, ma spero che il punto sia chiarito; i mattoncini funzionali non contengono sorprese nascoste e sono del tutto prevedibili. Poiché è sempre possibile utilizzare blocchi più piccoli della giusta dimensione e forma per sostituirne uno più grande e non vi è alcuna differenza tra due scatole della stessa dimensione e forma, si ha una trasparenza referenziale. Con i mattoni imperativi, non è sufficiente avere qualcosa della giusta dimensione e forma: devi sapere come è stato costruito il mattone. Non referenzialmente trasparente.

In un linguaggio funzionale puro, tutto ciò che devi vedere è la firma di una funzione per sapere cosa fa. Naturalmente, si potrebbe desiderare di guardare dentro per vedere come si comporta, ma non si hanno a guardare.

In un linguaggio imperativo, non si sa mai quali sorprese potrebbero nascondersi dentro.


"In un linguaggio funzionale puro, tutto ciò che devi vedere è la firma di una funzione per sapere cosa fa." - Non è generalmente vero. Sì, ipotizzando il polimorfismo parametrico possiamo concludere che una funzione di tipo (a, b) -> apuò essere solo la fstfunzione e che una funzione di tipo a -> apuò essere solo la identityfunzione, ma non si può necessariamente dire nulla su una funzione di tipo (a, a) -> a, ad esempio.
Jörg W Mittag

2

Siccome capisco approssimativamente il modello di sostituzione (con trasparenza referenziale (RT)), puoi decomporre una funzione nelle sue parti più semplici. Se l'espressione è RT, puoi decomporre l'espressione e ottenere sempre lo stesso risultato.

Sì, l'intuizione è giusta. Ecco alcuni suggerimenti per essere più precisi:

Come hai detto, qualsiasi espressione RT dovrebbe avere un single"risultato". Cioè, data factorial(5)un'espressione nel programma, dovrebbe sempre produrre lo stesso "risultato". Quindi, se un certo factorial(5)è nel programma e produce 120, dovrebbe sempre produrre 120 indipendentemente da quale "ordine di passi" viene espanso / calcolato - indipendentemente dal tempo .

Esempio: la factorialfunzione.

def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)

Ci sono alcune considerazioni con questa spiegazione.

Prima di tutto, tieni presente che i diversi modelli di valutazione (vedi ordine applicativo vs. ordine normale) possono produrre "risultati" diversi per la stessa espressione RT.

def first(y, z):
  return y

def second(x):
  return second(x)

first(2, second(3)) # result depends on eval. model

Nel codice sopra, firste secondsono referenzialmente trasparenti, eppure, l'espressione alla fine produce diversi "risultati" se valutata in ordine normale e ordine applicativo (in quest'ultimo caso, l'espressione non si ferma).

.... che porta all'uso del "risultato" tra virgolette. Poiché non è necessario arrestare un'espressione, potrebbe non produrre un valore. Quindi usare "risultato" è piuttosto sfocato. Si può dire che un'espressione RT produce sempre lo stesso computationsin un modello di valutazione.

Terzo, potrebbe essere richiesto di vedere due foo(50)apparire nel programma in posizioni diverse come espressioni diverse, ognuna con i propri risultati che potrebbero differire l'una dall'altra. Ad esempio, se il linguaggio consente un ambito dinamico, entrambe le espressioni, sebbene lessicalmente identiche, sono diverse. In perl:

sub foo {
    my $x = shift;
    return $x + $y; # y is dynamic scope var
}

sub a {
    local $y = 10;
    return &foo(50); # expanded to 60
}

sub b {
    local $y = 20;
    return &foo(50); # expanded to 70
}

L'ambito dinamico inganna perché rende facile per uno pensare che xsia l'unico input per foo, quando in realtà lo è xe y. Un modo per vedere la differenza è trasformare il programma in un programma equivalente senza ambito dinamico, ovvero passare esplicitamente i parametri, quindi invece di definire foo(x), definiamo foo(x, y)e passiamo yesplicitamente nei chiamanti.

Il punto è che siamo sempre sotto una functionmentalità: dato un certo input per un'espressione, ci viene dato un "risultato" corrispondente. Se diamo lo stesso input, dovremmo sempre aspettarci lo stesso "risultato".

Ora, che dire del seguente codice?

def foo():
   global y
   y = y + 1
   return y

y = 10
foo() # yields 11
foo() # yields 12

La fooprocedura interrompe RT perché ci sono ridefinizioni. Cioè, abbiamo definito yin un punto, e in seguito, ridefinito lo stesso y . Nell'esempio perl di cui sopra, le ys sono associazioni diverse sebbene condividano lo stesso nome di lettera "y". Qui le ys sono effettivamente le stesse. Ecco perché diciamo che il (ri) assegnamento è una meta operazione: stai infatti cambiando la definizione del tuo programma.

All'incirca, le persone di solito descrivono la differenza come segue: in un'impostazione libera da effetti collaterali, hai una mappatura da input -> output. In un'ambientazione "imperativa", hai input -> ouputnel contesto di un stateche può cambiare nel tempo.

Ora, invece di sostituire semplicemente le espressioni con i loro valori corrispondenti, si devono anche applicare trasformazioni statea ciascuna operazione che lo richiede (e, naturalmente, le espressioni possono consultare lo stesso stateper eseguire calcoli).

Quindi, se in un programma privo di effetti collaterali tutto ciò che dobbiamo sapere per calcolare un'espressione è il suo input individuale, in un programma imperativo, dobbiamo conoscere gli input e l'intero stato, per ogni fase computazionale. Il ragionamento è il primo a subire un duro colpo (ora, per eseguire il debug di una procedura problematica, sono necessari input e core dump). Alcuni trucchi sono resi poco pratici, come la memoizzazione. Ma anche la concorrenza e il parallelismo diventano molto più difficili.


1
Bello che tu abbia citato la memoizzazione. Questo può essere usato come un esempio di stato interno che non è visibile all'esterno: una funzione che utilizza la memoization è ancora referenzialmente trasparente anche se internamente usa stato e mutazione.
Giorgio,
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.