Często spotykam następujące stwierdzenia / argumenty:
- Czyste funkcjonalne języki programowania nie dopuszczają efektów ubocznych (dlatego są mało przydatne w praktyce, ponieważ każdy przydatny program ma skutki uboczne, np. Gdy wchodzi w interakcję ze światem zewnętrznym).
- Czyste funkcjonalne języki programowania nie pozwalają na napisanie programu, który zachowuje stan (co sprawia, że programowanie jest bardzo niewygodne, ponieważ w wielu aplikacjach potrzebujesz stanu).
Nie jestem ekspertem od języków funkcjonalnych, ale do tej pory rozumiałem te tematy.
Jeśli chodzi o punkt 1, możesz wchodzić w interakcje ze środowiskiem w czysto funkcjonalnych językach, ale musisz wyraźnie zaznaczyć kod (funkcje), który wprowadza skutki uboczne (np. W Haskell za pomocą typów monadycznych). Ponadto, o ile wiem, obliczanie efektów ubocznych (destrukcyjne aktualizowanie danych) powinno być również możliwe (przy użyciu typów monadycznych?), Nawet jeśli nie jest to preferowany sposób działania.
Jeśli chodzi o punkt 2, o ile wiem, możesz reprezentować stan, dzieląc wartości na kilka etapów obliczeniowych (w Haskell, znowu, używając typów monadycznych), ale nie mam praktycznego doświadczenia w tym zakresie, a moje rozumienie jest raczej niejasne.
Czy zatem powyższe dwa stwierdzenia są poprawne w jakimkolwiek sensie, czy są to tylko nieporozumienia na temat języków funkcjonalnych? Jeśli są to nieporozumienia, skąd się wzięły? Czy mógłbyś napisać (prawdopodobnie mały) fragment kodu ilustrujący idiomatyczny sposób Haskella do (1) implementacji efektów ubocznych i (2) implementacji obliczeń ze stanem?
źródło
Odpowiedzi:
Na potrzeby tej odpowiedzi definiuję „język czysto funkcjonalny” jako język funkcjonalny, w którym funkcje są referencyjnie przezroczyste, tj. Wielokrotne wywołanie tej samej funkcji z tymi samymi argumentami zawsze da takie same wyniki. Sądzę, że jest to zwykła definicja czysto funkcjonalnego języka.
Najłatwiejszym sposobem na osiągnięcie przejrzystości referencyjnej byłoby rzeczywiście uniemożliwienie efektów ubocznych, a tak naprawdę istnieją języki, w których tak jest (głównie te specyficzne dla danej dziedziny). Jednak z pewnością nie jest to jedyny sposób, a najbardziej funkcjonalne języki ogólnego przeznaczenia (Haskell, Clean, ...) pozwalają na efekt uboczny.
Mówienie również, że język programowania bez skutków ubocznych jest mało użyteczny w praktyce, nie jest tak naprawdę sprawiedliwe, myślę - z pewnością nie dla języków specyficznych dla domeny, ale nawet dla języków ogólnego przeznaczenia, wyobrażam sobie, że język może być całkiem użyteczny bez zapewniania efektów ubocznych . Może nie dla aplikacji konsolowych, ale myślę, że aplikacje GUI można ładnie zaimplementować bez skutków ubocznych, powiedzmy, w funkcjonalnym paradygmacie reaktywnym.
To nieco ponad uproszczenie. Sam system, w którym funkcje uboczne muszą być oznaczone jako takie (podobnie jak const-poprawność w C ++, ale z ogólnymi efektami ubocznymi) nie wystarczy, aby zapewnić przejrzystość referencyjną. Musisz upewnić się, że program nigdy nie może wywołać funkcji wiele razy z tymi samymi argumentami i uzyskać różne wyniki. Możesz to zrobić, tworząc takie rzeczy
readLine
być czymś, co nie jest funkcją (to właśnie robi Haskell z monadą IO) lub możesz uniemożliwić wielokrotne wywoływanie funkcji powodujących skutki uboczne przy użyciu tego samego argumentu (właśnie to robi Clean). W tym drugim przypadku kompilator zapewni, że za każdym razem, gdy wywołasz funkcję wywołującą skutki uboczne, zrobisz to z nowym argumentem i odrzuci każdy program, w którym dwukrotnie przekażesz ten sam argument funkcji wywołującej skutki uboczne.Ponownie, czysto funkcjonalny język może bardzo dobrze uniemożliwiać stan zmienny, ale z pewnością można być czystym i nadal mieć stan zmienny, jeśli zastosujesz go w taki sam sposób, jak opisałem z efektami ubocznymi powyżej. Naprawdę zmienny stan to kolejna forma efektów ubocznych.
To powiedziawszy, funkcjonalne języki programowania zdecydowanie zniechęcają do zmiany stanu - szczególnie te czyste. I nie sądzę, że to sprawia, że programowanie jest niewygodne - wręcz przeciwnie. Czasami (ale nie tak często) stanu zmiennego nie da się uniknąć bez utraty wydajności lub przejrzystości (dlatego języki takie jak Haskell mają udogodnienia dla stanu zmiennego), ale najczęściej może.
Myślę, że wiele osób po prostu czyta „funkcja musi dawać ten sam wynik, gdy wywoływana jest z tymi samymi argumentami” i wyciąga z tego wniosek, że nie jest możliwe zaimplementowanie czegoś takiego
readLine
lub kodu, który zachowuje stan zmienny. Więc po prostu nie są świadomi „kodów”, których mogą używać wyłącznie funkcjonalne języki, aby wprowadzić te rzeczy bez naruszania referencyjnej przejrzystości.Również zmienny stan mocno zniechęca w językach funkcjonalnych, więc nie jest przesadą zakładanie, że jest niedozwolony w językach funkcjonalnych.
Oto aplikacja w Pseudo-Haskell, która prosi użytkownika o imię i wita go. Pseudo-Haskell to język, który właśnie wymyśliłem, który ma system IO Haskella, ale używa bardziej konwencjonalnej składni, bardziej opisowych nazw funkcji i nie zawiera
do
adnotacji (ponieważ to tylko odwróciłoby uwagę od tego, jak dokładnie działa monada IO):Wskazówka jest taka, że
readLine
jest to wartość typuIO<String>
icomposeMonad
jest to funkcja, która pobiera argument typuIO<T>
(dla niektórych typówT
) i kolejny argument, który jest funkcją, która pobiera argument typuT
i zwraca wartość typuIO<U>
(dla niektórych typówU
).print
jest funkcją, która pobiera ciąg znaków i zwraca wartość typuIO<void>
.Wartość typu
IO<A>
jest wartością, która „koduje” dane działanie, które generuje wartość typuA
.composeMonad(m, f)
tworzy nowąIO
wartość, która koduje akcję,m
po której następuje akcjaf(x)
, gdziex
wartość jest wytwarzana przez wykonanie akcjim
.Stan zmienny wyglądałby tak:
Oto
mutableVariable
funkcja, która przyjmuje wartość dowolnego typuT
i tworzyMutableVariable<T>
. FunkcjagetValue
przyjmujeMutableVariable
i zwraca wartość,IO<T>
która generuje jej bieżącą wartość.setValue
przyjmuje AMutableVariable<T>
i AT
i zwraca A,IO<void>
która ustawia wartość.composeVoidMonad
jest taki sam,composeMonad
z wyjątkiem tego, że pierwszy argument jest argumentemIO
, który nie daje sensownej wartości, a drugi argument to kolejna monada, a nie funkcja, która zwraca monadę.W Haskell jest trochę cukru syntaktycznego, który sprawia, że cała ta próba jest mniej bolesna, ale nadal jest oczywiste, że stan zmienności jest czymś, czego język tak naprawdę nie chce.
źródło
counter
, tj.increaseCounter(counter)
?main
będzie tą, która zostanie faktycznie wykonana. Poza zwrotem IOmain
nie ma możliwości wykonywaniaIO
akcji (bez użycia okropnie złych funkcji, które mająunsafe
w ich imieniu).IO
wartościach niszczących . Nie rozumiałem, czy odnosi się on do dopasowania wzorca, tj. Do faktu, że można zdekonstruować wartości typu danych algebraicznych, ale nie można użyć dopasowania wzorca, aby to zrobić zIO
wartościami.IMHO jesteś zdezorientowany, ponieważ istnieje różnica między czystym językiem a czystą funkcją . Zacznijmy od funkcji. Funkcja jest czysta, jeśli (przy takim samym wejściu) zawsze zwróci tę samą wartość i nie spowoduje żadnych zauważalnych skutków ubocznych. Typowymi przykładami są funkcje matematyczne, takie jak f (x) = x * x. Teraz rozważ implementację tej funkcji. Byłby czysty w większości języków, nawet tych, które nie są ogólnie uważane za czyste języki funkcjonalne, np. ML. Nawet metodę Java lub C ++ z takim zachowaniem można uznać za czystą.
Czym jest czysty język? Ściśle mówiąc, można oczekiwać, że czysty język nie pozwala wyrazić funkcji, które nie są czyste. Nazwijmy to idealistyczną definicją czystego języka. Takie zachowanie jest wysoce pożądane. Dlaczego? Cóż, miłą rzeczą w programie składającym się tylko z czystych funkcji jest to, że można zastąpić aplikację funkcji jej wartością bez zmiany znaczenia programu. Ułatwia to rozumowanie programów, ponieważ gdy znasz wynik, możesz zapomnieć o sposobie jego obliczenia. Czystość może również umożliwiać kompilatorowi przeprowadzanie pewnych agresywnych optymalizacji.
Co jeśli potrzebujesz jakiegoś stanu wewnętrznego? Można naśladować stan w czystym języku, po prostu dodając stan przed obliczeniami jako parametr wejściowy i stan po obliczeniach jako część wyniku. Zamiast tego
Int -> Bool
dostaniesz coś takiegoInt -> State -> (Bool, State)
. Po prostu wyraźnie określasz zależność (co jest uważane za dobrą praktykę w każdym paradygmacie programowania). BTW, istnieje monada, która jest szczególnie eleganckim sposobem łączenia takich funkcji naśladujących stan z większymi funkcjami naśladującymi stan. W ten sposób zdecydowanie możesz „utrzymać stan” w czystym języku. Ale musisz to wyrazić jasno.Czy to oznacza, że mogę wchodzić w interakcje z otoczeniem? W końcu użyteczny program musi wchodzić w interakcje ze światem rzeczywistym, aby był użyteczny. Ale nakłady i wyniki oczywiście nie są czyste. Zapisywanie określonego bajtu w określonym pliku może być w porządku po raz pierwszy. Ale wykonanie dokładnie tej samej operacji po raz drugi może zwrócić błąd, ponieważ dysk jest pełny. Oczywiście nie ma czystego języka (w idealistycznym znaczeniu), który mógłby zapisywać do pliku.
Stajemy więc przed dylematem. Chcemy głównie czystych funkcji, ale niektóre działania niepożądane są absolutnie wymagane, a te nie są czyste. Teraz realistyczną definicją czystego języka byłoby, że muszą istnieć pewne środki, aby oddzielić czyste części od innych części. Mechanizm musi zapewnić, że żadna nieczysta operacja nie przedostanie się do czystych części.
W Haskell odbywa się to za pomocą typu IO. Nie można zniszczyć wyniku We / Wy (bez niebezpiecznych mechanizmów). W ten sposób możesz przetwarzać wyniki We / Wy tylko za pomocą funkcji zdefiniowanych w samym module We / Wy. Na szczęście istnieją bardzo elastyczne kombinatory, które pozwalają pobrać wynik IO i przetworzyć go w funkcję, o ile funkcja ta zwraca kolejny wynik IO. Ten kombinator nazywa się bind (lub
>>=
) i ma typIO a -> (a -> IO b) -> IO b
. Jeśli uogólnisz tę koncepcję, dojdziesz do klasy monad, a IO będzie tego przykładem.źródło
unsafe
w nazwie) nie spełnia twojej idealistycznej definicji. W Haskell nie ma nieczystych funkcji (ponownie ignorowanieunsafePerformIO
i ko.).readFile
iwriteFile
zawsze zwróci tę samąIO
wartość, biorąc pod uwagę te same argumenty. Na przykład dwa fragmenty kodulet x = writeFile "foo.txt" "bar" in x >> x
iwriteFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"
zrobią to samo.IO Something
? Jeśli tak, to jest całkowicie możliwe wywołanie funkcji IO dwa razy z tym samym argumentem:putStrLn "hello" >> putStrLn "hello"
- tutaj oba wywołaniaputStrLn
mają ten sam argument. Oczywiście nie stanowi to problemu, ponieważ, jak powiedziałem wcześniej, oba połączenia spowodują taką samą wartość IO.writeFile "foo.txt" "bar"
nie może powodować błąd, ponieważ oceny wywołanie funkcji nie nie wykonać działanie. Jeśli mówisz, że w moim poprzednim przykładzie wersja zlet
ma tylko jedną możliwość spowodowania błędu We / Wy, podczas gdy wersja bezlet
ma dwie, jesteś w błędzie. Obie wersje mają dwie możliwości niepowodzenia IO. Ponieważlet
wersja ocenia wywołanie dowriteFile
tylko raz, podczas gdy wersja bezlet
ocenia ją dwukrotnie, widać, że nie ma znaczenia, jak często funkcja jest wywoływana. Ważne jest tylko to, jak często wynikowe ...putStrLn
Funkcja wykonuje dokładnie jeden argument, który jest typuString
. Jeśli mi nie wierzysz, popatrz na tego typu:String -> IO ()
. Z pewnością nie przyjmuje żadnych argumentów typuIO
- tworzy wartość tego typu.