Skutki uboczne Przełamywanie przejrzystości odniesienia

11

Programowanie funkcjonalne w Scali wyjaśnia wpływ efektu ubocznego na przełamanie przejrzystości referencyjnej:

efekt uboczny, co oznacza pewne naruszenie przejrzystości referencyjnej.

Przeczytałem część SICP , która omawia użycie „modelu zastępczego” do oceny programu.

Ponieważ z grubsza rozumiem model podstawienia z referencyjną przezroczystością (RT), możesz rozłożyć funkcję na jej najprostsze części. Jeśli wyrażenie ma wartość RT, możesz go zdekomponować i zawsze uzyskać ten sam wynik.

Jednak, jak stwierdza powyższy cytat, stosowanie efektów ubocznych może / spowoduje uszkodzenie modelu substytucyjnego.

Przykład:

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

Jeśli fooi bar nie mają skutków ubocznych, wykonanie dowolnej funkcji zawsze zwróci ten sam wynik x. Ale jeśli mają skutki uboczne, zmienią zmienną, która zakłóca / wrzuca klucz do modelu substytucji.

Czuję się swobodnie z tym wyjaśnieniem, ale nie do końca go rozumiem.

Proszę mnie poprawić i wypełnić wszelkie dziury w odniesieniu do skutków ubocznych przełamujących RT, omawiając również wpływ na model substytucyjny.

Kevin Meredith
źródło

Odpowiedzi:

20

Zacznijmy od definicji przezroczystości referencyjnej :

Mówi się, że wyrażenie jest referencyjnie przezroczyste, jeśli można je zastąpić jego wartością bez zmiany zachowania programu (innymi słowy, dając program, który ma takie same efekty i dane wyjściowe na tym samym wejściu).

Oznacza to, że (na przykład) możesz zamienić 2 + 5 na 7 w dowolnej części programu, a program powinien nadal działać. Ten proces nazywa się zastępowaniem. Zmiana jest ważna tylko wtedy, gdy 2 + 5 można zastąpić liczbą 7 bez wpływu na inną część programu .

Powiedzmy, że mam klasę o nazwie Bazz funkcjami Fooi Barna niej. Dla uproszczenia powiemy tylko to Fooi Barobie zwracają przekazaną wartość. Tak Foo(2) + Bar(5) == 7, jak można się spodziewać. Przezroczystość referencyjna gwarantuje, że możesz zastąpić wyrażenie Foo(2) + Bar(5)wyrażeniem w 7dowolnym miejscu w programie, a program nadal będzie działał identycznie.

Ale co, jeśli Foozwróci wartość przekazaną, ale Barzwróci wartość przekazaną, plus ostatnią podaną wartość Foo? Jest to wystarczająco łatwe, jeśli przechowujesz wartość Foozmiennej lokalnej w Bazklasie. Cóż, jeśli początkowa wartość tej zmiennej lokalnej wynosi 0, wyrażenie Foo(2) + Bar(5)zwróci oczekiwaną wartość 7przy pierwszym wywołaniu, ale zwróci 9przy drugim wywołaniu.

To narusza przejrzystość referencyjną na dwa sposoby. Po pierwsze, nie można liczyć, że Bar zwróci to samo wyrażenie przy każdym wywołaniu. Po drugie, wystąpił efekt uboczny, mianowicie wywołanie Foo wpływa na wartość zwracaną Bar. Ponieważ nie możesz już zagwarantować, że Foo(2) + Bar(5)będzie równa 7, nie możesz już zastępować.

To właśnie oznacza w praktyce przejrzystość referencyjna; referencyjnie przezroczysta funkcja przyjmuje pewną wartość i zwraca pewną odpowiednią wartość, bez wpływu na inny kod w innym miejscu w programie, i zawsze zwraca to samo wyjście przy tych samych danych wejściowych.

Robert Harvey
źródło
5
Więc zerwania RTwyłącza was z pomocą substitution model.duży problem ze nie jest w stanie użyć substitution modeljest moc używania rozumu o programie?
Kevin Meredith
Dokładnie tak.
Robert Harvey
1
+1 cudownie jasna i zrozumiała odpowiedź. Dziękuję Ci.
Racheet
2
Również jeśli te funkcje są przezroczyste lub „czyste”, kolejność, w jakiej faktycznie działają, nie jest ważne, nie obchodzi nas, czy foo () lub bar () uruchamiają się w pierwszej kolejności, aw niektórych przypadkach mogą nigdy nie ocenić, czy nie są potrzebne
Zachary K
1
Jeszcze inną zaletą RT jest to, że drogie wyrażenia względnie przezroczyste mogą być buforowane (ponieważ ich ocena raz lub dwa razy powinna dać dokładnie ten sam wynik).
dcastro,
3

Wyobraź sobie, że próbujesz zbudować ścianę i otrzymałeś asortyment pudełek o różnych rozmiarach i kształtach. Musisz wypełnić konkretny otwór w kształcie litery L w ścianie; powinieneś poszukać pudełka w kształcie litery L, czy możesz zastąpić dwa proste pudełka o odpowiednim rozmiarze?

W świecie funkcjonalnym odpowiedź brzmi: każde z tych rozwiązań będzie działać. Budując funkcjonalny świat, nigdy nie musisz otwierać skrzynek, aby zobaczyć, co jest w środku.

W świecie imperatywnym niebezpieczne jest budowanie ściany bez sprawdzania zawartości każdego pudełka i porównywania ich z zawartością każdego innego pudełka:

  • Niektóre zawierają silne magnesy i wypchną inne skrzynki magnetyczne ze ściany, jeśli nie zostaną odpowiednio ustawione.
  • Niektóre są bardzo gorące lub zimne i źle zareagują, jeśli zostaną umieszczone w sąsiednich przestrzeniach.

Myślę, że przestanę, zanim zmarnuję twój czas na bardziej nieprawdopodobne metafory, ale mam nadzieję, że o to chodzi; funkcjonalne klocki nie zawierają ukrytych niespodzianek i są całkowicie przewidywalne. Ponieważ zawsze możesz użyć mniejszych bloków o odpowiednim rozmiarze i kształcie, aby zastąpić większy i nie ma różnicy między dwoma polami o tym samym rozmiarze i kształcie, masz przejrzystość referencyjną. W przypadku cegieł imperatywnych nie wystarczy mieć coś odpowiedniego rozmiaru i kształtu - musisz wiedzieć, jak zbudowano cegłę. Nie referencyjnie przejrzysty.

W czysto funkcjonalnym języku wszystko, co musisz zobaczyć, to podpis funkcji, aby wiedzieć, co ona robi. Oczywiście możesz zajrzeć do środka, aby zobaczyć, jak dobrze sobie radzi, ale nie musisz patrzeć.

W imperatywnym języku nigdy nie wiesz, jakie niespodzianki mogą kryć się w środku.

itsbruce
źródło
„W czysto funkcjonalnym języku wystarczy zobaczyć podpis funkcji, aby wiedzieć, co ona robi.” - To nie jest ogólnie prawda. Tak, przy założeniu parametrycznego polimorfizmu możemy stwierdzić, że funkcja typu (a, b) -> amoże być tylko fstfunkcją i że funkcja typu a -> amoże być tylko identityfunkcją, ale niekoniecznie można powiedzieć (a, a) -> ana przykład o funkcji typu .
Jörg W Mittag
2

Ponieważ z grubsza rozumiem model podstawienia (z przezroczystością referencyjną (RT)), możesz zdekomponować funkcję na jej najprostsze części. Jeśli wyrażenie ma wartość RT, możesz go zdekomponować i zawsze uzyskać ten sam wynik.

Tak, intuicja jest całkiem słuszna. Oto kilka wskazówek, aby uzyskać bardziej precyzyjne:

Jak powiedziałeś, każde wyrażenie RT powinno mieć single„wynik”. To znaczy, biorąc pod uwagę factorial(5)wyrażenie w programie, powinno zawsze dawać ten sam „wynik”. Tak więc, jeśli pewna factorial(5)jest w programie i daje 120, to zawsze powinna dawać 120, niezależnie od tego, która „kolejność kroków” jest rozszerzana / obliczana - niezależnie od czasu .

Przykład: factorialfunkcja.

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

Istnieje kilka uwag do tego wyjaśnienia.

Przede wszystkim należy pamiętać, że różne modele oceny (patrz kolejność aplikacyjna vs. normalna) mogą dawać różne „wyniki” dla tego samego wyrażenia RT.

def first(y, z):
  return y

def second(x):
  return second(x)

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

W powyższym kodzie firsti secondsą referencyjnie przezroczyste, a jednak wyrażenie na końcu daje różne „wyniki”, jeśli są oceniane w normalnej kolejności i kolejności stosowania (w tym drugim przypadku wyrażenie się nie zatrzymuje).

.... co prowadzi do użycia „wyniku” w cudzysłowie. Ponieważ wyrażenie nie jest wymagane do zatrzymania, może nie wygenerować wartości. Zatem użycie „wyniku” jest niejasne. Można powiedzieć, że wyrażenie RT zawsze daje to samo computationsw modelu oceny.

Po trzecie, może być wymagane zobaczenie dwóch foo(50)pojawiających się w programie w różnych lokalizacjach jako różnych wyrażeń - każdy z nich daje własne wyniki, które mogą się od siebie różnić. Na przykład, jeśli język dopuszcza zakres dynamiczny, oba wyrażenia, choć leksykalnie identyczne, są różne. W perlu:

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
}

Zakres dynamiczny wprowadza w błąd, ponieważ ułatwia myślenie, że xjest to jedyny wkład foo, podczas gdy w rzeczywistości jest xi y. Jednym ze sposobów dostrzeżenia różnicy jest przekształcenie programu w równoważny bez zakresu dynamicznego - to znaczy przekazanie jawnie parametrów, więc zamiast definiować foo(x), definiujemy foo(x, y)i przekazujemy yjawnie w wywołujących.

Chodzi o to, że zawsze jesteśmy functionnastawieni na myślenie: biorąc pod uwagę pewien wkład wyrażenia, otrzymujemy odpowiedni „wynik”. Jeśli podamy ten sam wkład, zawsze powinniśmy oczekiwać tego samego „wyniku”.

A co z następującym kodem?

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

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

fooPostępowanie łamie RT ponieważ istnieje redefinicje. Oznacza to, że zdefiniowaliśmy yw jednym punkcie, a następnie zdefiniowaliśmy to samo y . W powyższym przykładzie perla ys są różnymi powiązaniami, chociaż mają tę samą literę o nazwie „y”. Tutaj ysą w rzeczywistości takie same. Dlatego mówimy, że (ponowne) przypisanie jest metaoperacją : w rzeczywistości zmieniasz definicję swojego programu.

Z grubsza ludzie zwykle przedstawiają różnicę w następujący sposób: w ustawieniu bez efektów ubocznych masz mapowanie od input -> output. W ustawieniu „imperatywnym” masz input -> ouputw kontekście coś, stateco może się zmieniać w czasie.

Teraz zamiast po prostu zastępować wyrażenia odpowiadającymi im wartościami, należy również zastosować transformacje do statekażdej operacji, która tego wymaga (i oczywiście wyrażenia mogą się z nimi konsultować, stateaby wykonać obliczenia).

Tak więc, jeśli w programie wolnym od skutków ubocznych wszystko, co musimy wiedzieć, aby obliczyć wyrażenie, to jego indywidualne dane wejściowe, w programie imperatywnym musimy znać dane wejściowe i cały stan dla każdego kroku obliczeniowego. Rozumowanie jest pierwszym, które cierpi z powodu dużego ciosu (teraz, aby debugować problematyczną procedurę, potrzebujesz danych wejściowych i zrzutu pamięci). Niektóre triki są niepraktyczne, jak na przykład zapamiętywanie. Ale współbieżność i równoległość stają się znacznie trudniejsze.

Thiago Silva
źródło
1
Miło, że wspomniałeś o zapamiętywaniu. Można to wykorzystać jako przykład stanu wewnętrznego, który nie jest widoczny na zewnątrz: funkcja korzystająca z zapamiętywania jest wciąż względnie przezroczysta, chociaż wewnętrznie używa stanu i mutacji.
Giorgio