Błędne wyobrażenia o językach funkcjonalnych?

39

Często spotykam następujące stwierdzenia / argumenty:

  1. 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).
  2. 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?

Giorgio
źródło
7
Myślę, że większość z tego zależy od tego, czym jest „czysty” język funkcjonalny.
jk.
@jk: Aby uniknąć problemu definiowania „czystych” języków funkcjonalnych, przyjmij czystość w sensie Haskella (który jest dobrze zdefiniowany). W jakich warunkach funkcjonalny język można uznać za „czysty” może być tematem przyszłego pytania.
Giorgio,
Obie odpowiedzi zawierają wiele wyjaśniających pomysłów i trudno mi było wybrać, który z nich zaakceptować. Postanowiłem zaakceptować odpowiedź sepp2k ze względu na dodatkowe przykłady pseudokodu.
Giorgio

Odpowiedzi:

26

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.

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).

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.

Jeśli chodzi o punkt 1, możesz wchodzić w interakcje ze środowiskiem w czysto funkcjonalnych językach, ale musisz wyraźnie oznaczyć kod (funkcje), który je wprowadza (np. W Haskell za pomocą typów monadycznych).

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 rzeczyreadLinebyć 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.

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).

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.

Jeśli są to nieporozumienia, skąd się wzięły?

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 readLinelub 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.

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?

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 doadnotacji (ponieważ to tylko odwróciłoby uwagę od tego, jak dokładnie działa monada IO):

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

Wskazówka jest taka, że readLinejest to wartość typu IO<String>i composeMonadjest to funkcja, która pobiera argument typu IO<T>(dla niektórych typów T) i kolejny argument, który jest funkcją, która pobiera argument typu Ti zwraca wartość typu IO<U>(dla niektórych typów U). printjest funkcją, która pobiera ciąg znaków i zwraca wartość typu IO<void>.

Wartość typu IO<A>jest wartością, która „koduje” dane działanie, które generuje wartość typu A. composeMonad(m, f)tworzy nową IOwartość, która koduje akcję, mpo której następuje akcja f(x), gdzie xwartość jest wytwarzana przez wykonanie akcji m.

Stan zmienny wyglądałby tak:

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) )

Oto mutableVariablefunkcja, która przyjmuje wartość dowolnego typu Ti tworzy MutableVariable<T>. Funkcja getValueprzyjmuje MutableVariablei zwraca wartość, IO<T>która generuje jej bieżącą wartość. setValueprzyjmuje A MutableVariable<T>i A Ti zwraca A, IO<void>która ustawia wartość. composeVoidMonadjest taki sam, composeMonadz wyjątkiem tego, że pierwszy argument jest argumentem IO, 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.

sepp2k
źródło
Świetna odpowiedź, wyjaśniająca wiele pomysłów. Czy ostatni wiersz fragmentu kodu powinien zawierać nazwę counter, tj. increaseCounter(counter)?
Giorgio,
@Giorgio Tak, powinno. Naprawiony.
sepp2k,
1
@Giorgio Jedną rzeczą, o której zapomniałem wyraźnie wspomnieć w moim poście, jest to, że zwrócona akcja IO mainbędzie tą, która zostanie faktycznie wykonana. Poza zwrotem IO mainnie ma możliwości wykonywania IOakcji (bez użycia okropnie złych funkcji, które mają unsafew ich imieniu).
sepp2k,
DOBRZE. scarfridge wspomniał także o IOwartoś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ć z IOwartościami.
Giorgio,
16

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 -> Booldostaniesz coś takiego Int -> 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 typ IO a -> (a -> IO b) -> IO b. Jeśli uogólnisz tę koncepcję, dojdziesz do klasy monad, a IO będzie tego przykładem.

szalik
źródło
4
Naprawdę nie rozumiem, w jaki sposób Haskell (ignorując jakąkolwiek funkcję unsafew nazwie) nie spełnia twojej idealistycznej definicji. W Haskell nie ma nieczystych funkcji (ponownie ignorowanie unsafePerformIOi ko.).
sepp2k,
4
readFilei writeFilezawsze zwróci tę samą IOwartość, biorąc pod uwagę te same argumenty. Na przykład dwa fragmenty kodu let x = writeFile "foo.txt" "bar" in x >> xi writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"zrobią to samo.
sepp2k,
3
@AidanCully Co rozumiesz przez „funkcję IO”? Funkcja, która zwraca wartość typu 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łania putStrLnmają ten sam argument. Oczywiście nie stanowi to problemu, ponieważ, jak powiedziałem wcześniej, oba połączenia spowodują taką samą wartość IO.
sepp2k,
3
@scarfridge Ocenianie 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 z letma tylko jedną możliwość spowodowania błędu We / Wy, podczas gdy wersja bez letma dwie, jesteś w błędzie. Obie wersje mają dwie możliwości niepowodzenia IO. Ponieważ letwersja ocenia wywołanie do writeFiletylko raz, podczas gdy wersja bez letocenia ją dwukrotnie, widać, że nie ma znaczenia, jak często funkcja jest wywoływana. Ważne jest tylko to, jak często wynikowe ...
sepp2k,
6
@AidanCully „Mechanizm monady” nie przekazuje niejawnych parametrów. putStrLnFunkcja wykonuje dokładnie jeden argument, który jest typu String. Jeśli mi nie wierzysz, popatrz na tego typu: String -> IO (). Z pewnością nie przyjmuje żadnych argumentów typu IO- tworzy wartość tego typu.
sepp2k,