Dlaczego Haskell (czasami) nazywany jest „najlepszym językiem imperatywnym”?


84

(Mam nadzieję, że to pytanie jest na temat - próbowałem poszukać odpowiedzi, ale nie znalazłem ostatecznej odpowiedzi. Jeśli zdarzy się, że jest to nie na temat lub już odpowiedziałem, moderuj / usuń je.)

Pamiętam, że kilka razy słyszałem / czytałem pół-żartobliwy komentarz, że Haskell jest najlepszym językiem imperatywnym , co oczywiście brzmi dziwnie, ponieważ Haskell jest zwykle najbardziej znany ze swoich funkcji .

Więc moje pytanie brzmi: jakie cechy / cechy (jeśli w ogóle) Haskella dają powód, by uzasadnić uznanie go za najlepszy język imperatywny - czy też jest to bardziej żart?


3
donsbot.wordpress.com/2007/03/10/… <- Programowalny średnik.
vivian,

11
Ten cytat prawdopodobnie pochodzi z końca Wstępu do rozwiązywania problemów z niezręcznym składem: monadyczne wejścia / wyjścia, współbieżność, wyjątki i obcojęzyczne wywołania w języku Haskell, który mówi: „Krótko mówiąc, Haskell jest najważniejszym na świecie imperatywnym językiem programowania”.
Russell O'Connor,

@Russel: dziękuję za wskazanie najbardziej prawdopodobnego źródła tego powiedzenia (jest nim sam SPJ)!
hvr

możesz zrobić ścisłe imperatywne OO z Haskellem: ekmett / structs
Janus Troelsen

Odpowiedzi:


92

Uważam to za półprawdę. Haskell ma niesamowitą zdolność abstrakcji, która obejmuje abstrakcje nad imperatywnymi ideami. Na przykład Haskell nie ma wbudowanego imperatywu while, ale możemy go po prostu napisać i teraz robi:

while :: (Monad m) => m Bool -> m () -> m ()
while cond action = do
    c <- cond
    if c 
        then action >> while cond action
        else return ()

Ten poziom abstrakcji jest trudny dla wielu języków imperatywnych. Można to zrobić w językach imperatywnych, które mają zamknięcia; na przykład. Python i C #.

Ale Haskell ma również (wysoce unikalną) zdolność do charakteryzowania dozwolonych skutków ubocznych za pomocą klas Monad. Na przykład, jeśli mamy funkcję:

foo :: (MonadWriter [String] m) => m Int

Może to być funkcja „imperatywna”, ale wiemy, że może zrobić tylko dwie rzeczy:

  • „Wyprowadza” strumień ciągów
  • zwraca Int

Nie może drukować na konsoli ani nawiązywać połączeń sieciowych itp. W połączeniu ze zdolnością do abstrakcji można pisać funkcje działające na „dowolne obliczenia, które generują strumień” itp.

Tak naprawdę chodzi o zdolności abstrakcyjne Haskella, co czyni go bardzo dobrym językiem imperatywnym.

Jednak fałszywą połową jest składnia. Uważam, że Haskell jest dość rozwlekły i niezręczny w użyciu w imperatywnym stylu. Oto przykład obliczenia w trybie rozkazującym przy użyciu powyższej whilepętli, które znajduje ostatni element połączonej listy:

lastElt :: [a] -> IO a
lastElt [] = fail "Empty list!!"
lastElt xs = do
    lst <- newIORef xs
    ret <- newIORef (head xs)
    while (not . null <$> readIORef lst) $ do
        (x:xs) <- readIORef lst
        writeIORef lst xs
        writeIORef ret x
    readIORef ret

Całe to śmieci IORef, podwójny odczyt, konieczność wiązania wyniku odczytu, fmapping ( <$>), aby operować na wyniku obliczeń wbudowanych ... to wszystko jest po prostu bardzo skomplikowane. Z funkcjonalnego punktu widzenia ma to wiele sensu , ale języki imperatywne mają tendencję do zamiatania większości tych szczegółów pod dywan, aby ułatwić ich użycie.

Trzeba przyznać, że być może, gdybyśmy użyli whilekombinatora o innym stylu, byłoby to czystsze. Ale jeśli podejmiesz tę filozofię wystarczająco daleko (używając bogatego zestawu kombinatorów, aby wyrazić siebie jasno), to ponownie dojdziesz do programowania funkcjonalnego. Haskell w stylu rozkazującym po prostu nie „płynie” jak dobrze zaprojektowany język imperatywny, np. Python.

Podsumowując, dzięki syntaktycznemu liftingowi twarzy Haskell może być najlepszym językiem imperatywnym. Ale z natury liftingu twarzy byłoby to zastąpienie czegoś wewnętrznie pięknego i prawdziwego czymś zewnętrznie pięknym i fałszywym.

EDYCJA : Porównaj lastEltz tą transliteracją Pythona:

def last_elt(xs):
    assert xs, "Empty list!!"
    lst = xs
    ret = xs.head
    while lst:
        ret = lst.head
        lst = lst.tail
    return ret 

Ta sama liczba linii, ale każda linia ma trochę mniej szumów.


EDYCJA 2

Cóż to jest warte, tak wygląda czysty zamiennik w Haskell:

lastElt = return . last

Otóż ​​to. Lub, jeśli zabronisz mi używania Prelude.last:

lastElt [] = fail "Unsafe lastElt called on empty list"
lastElt [x] = return x
lastElt (_:xs) = lastElt xs

Lub, jeśli chcesz, aby działał na dowolnej Foldablestrukturze danych i zdajesz sobie sprawę, że w rzeczywistości nie musisz IO obsługiwać błędów:

import Data.Foldable (Foldable, foldMap)
import Data.Monoid (Monoid(..), Last(..))

lastElt :: (Foldable t) => t a -> Maybe a
lastElt = getLast . foldMap (Last . Just)

z Map, na przykład:

λ➔ let example = fromList [(10, "spam"), (50, "eggs"), (20, "ham")] :: Map Int String
λ➔ lastElt example
Just "eggs"

(.)Operator złożenie funkcji .


2
Możesz sprawić, że szum IORef będzie znacznie mniej irytujący, tworząc więcej abstrakcji.
sierpień

1
@augustss, hmm, jestem tego ciekawy. Czy masz na myśli więcej abstrakcji na poziomie domeny, czy po prostu budowanie bogatszego imperatywnego języka językowego. ”W przypadku pierwszego, zgadzam się - ale mój umysł kojarzy programowanie imperatywne z niską abstrakcją (moja hipoteza robocza jest taka, że ​​wraz ze wzrostem abstrakcji styl zbiega się na funkcjonalnym). W przypadku tego drugiego bardzo chciałbym zobaczyć, co masz na myśli, ponieważ nie mogę sobie wyobrazić, jak to jest z głowy.
luqui,

2
@luqui Używanie ST byłoby dobrym przykładem do scharakteryzowania dozwolonych skutków ubocznych. Jako bonus można wrócić do czystego obliczenia z ST.
fuz

5
Używanie Pythona jako porównania nie jest do końca sprawiedliwe - jak mówisz, jest dobrze zaprojektowany, jeden z najczystszych pod względem składniowym języków imperatywnych, które znam. To samo porównanie wskazywałoby, że większość języków imperatywnych jest niewygodna w użyciu w stylu imperatywnym ... chociaż być może dokładnie to miałeś na myśli. ;]
CA McCann

5
Przypis do rozmowy, dla potomnych: @augustss stosując polimorfizm ad hoc do make IORefs ukryte , a przynajmniej stara się i jest udaremnione przez zmiany GHC. : [
CA McCann

22

To nie jest żart i wierzę w to. Postaram się, aby to było dostępne dla tych, którzy nie znają żadnego Haskella. Haskell używa notacji do (między innymi), aby umożliwić pisanie kodu imperatywnego (tak, używa monad, ale nie martw się o to). Oto kilka zalet, które daje ci Haskell:

  • Łatwe tworzenie podprogramów. Powiedzmy, że chcę, aby funkcja wypisała wartość na stdout i stderr. Mogę napisać co następuje, definiując podprogram za pomocą jednej krótkiej linii:

    do let printBoth s = putStrLn s >> hPutStrLn stderr s
       printBoth "Hello"
       -- Some other code
       printBoth "Goodbye"
    
  • Łatwy do przekazania kod. Biorąc pod uwagę, że napisałem powyższe, jeśli teraz chcę użyć printBothfunkcji do wydrukowania całej listy ciągów, można to łatwo zrobić, przekazując mój podprogram do mapM_funkcji:

    mapM_ printBoth ["Hello", "World!"]
    

    Innym przykładem, choć nie jest to konieczne, jest sortowanie. Powiedzmy, że chcesz sortować łańcuchy wyłącznie według długości. Możesz pisać:

    sortBy (\a b -> compare (length a) (length b)) ["aaaa", "b", "cc"]
    

    Co da ci ["b", "cc", "aaaa"]. (Możesz też napisać to krócej, ale na razie nieważne).

  • Łatwy do ponownego użycia kod. Ta mapM_funkcja jest często używana i zastępuje pętle for-each w innych językach. Istnieje również foreverfunkcja, która działa jak while (prawda) i różne inne funkcje, które można przekazać kod i wykonać go na różne sposoby. Więc pętle w innych językach są zastępowane przez te funkcje kontrolne w Haskell (które nie są specjalne - możesz je bardzo łatwo zdefiniować samodzielnie). Ogólnie rzecz biorąc, utrudnia to błędne określenie warunku pętli, podobnie jak pętle for-each trudniej jest popełnić błąd niż odpowiedniki iteratorów z długiej ręki (np. W Javie) lub pętle indeksujące tablice (np. W C).

  • Wiążące nie przypisanie. Zasadniczo możesz przypisać do zmiennej tylko raz (podobnie jak pojedyncze przypisanie statyczne). Eliminuje to wiele nieporozumień dotyczących możliwych wartości zmiennej w danym punkcie (jej wartość jest ustawiana tylko w jednej linii).
  • Zawarte skutki uboczne. Powiedzmy, że chcę odczytać linię ze stdin i zapisać ją na stdout po zastosowaniu do niej jakiejś funkcji (nazwiemy to foo). Możesz pisać:

    do line <- getLine
       putStrLn (foo line)
    

    Wiem od razu, że foonie ma to żadnych nieoczekiwanych skutków ubocznych (takich jak aktualizacja zmiennej globalnej, zwolnienie pamięci itp.), Ponieważ musi to być typ String -> String, co oznacza, że ​​jest to czysta funkcja; niezależnie od wartości, którą podam, za każdym razem musi zwracać ten sam wynik, bez skutków ubocznych. Haskell ładnie oddziela kod powodujący efekty uboczne od czystego kodu. W czymś takim jak C, czy nawet Java, nie jest to oczywiste (czy ta metoda getFoo () zmienia stan? Miałbyś nadzieję, że nie, ale może tak ...).

  • Zbieranie śmieci. Wiele języków jest obecnie zbieranych jako śmieci, ale warto o nich wspomnieć: nie ma kłopotów z przydzielaniem i zwalnianiem pamięci.

Poza tym prawdopodobnie jest jeszcze kilka zalet, ale to są te, które przychodzą na myśl.


9
Dodałbym do tego silne bezpieczeństwo typu. Haskell pozwala kompilatorowi wyeliminować dużą klasę błędów. Po niedawnej pracy nad jakimś kodem w Javie przypomniałem sobie, jak okropne są wskaźniki zerowe i ile brakuje OOP bez typów sum.
Michael Snoyman

1
Dziękuję za twoje opracowanie! Twoje wspomniane zalety zdają się sprowadzać do tego, że Haskell traktował efekty „imperatywne” jako obiekty pierwszej klasy (które w ten sposób można łączyć) wraz ze zdolnością do „zawierania” tych efektów w określonym zakresie. Czy to jest odpowiednie skompresowane podsumowanie?
hvr

19
@Michael Snoyman: Ale typy sum są łatwe w OOP! Po prostu zdefiniuj klasę abstrakcyjną, która reprezentuje kodowanie Church twojego typu danych, podklasy dla przypadków, interfejsy dla klas, które mogą przetwarzać każdy przypadek, a następnie przekaż obiekty obsługujące każdy interfejs do obiektu sumy, używając polimofizmu podtypu do sterowania przepływem (jak powinieneś). Nie może być prostsze. Dlaczego nienawidzisz wzorców projektowych?
CA McCann

9
@camccann Wiem, że żartujesz, ale zasadniczo to właśnie zaimplementowałem w swoim projekcie.
Michael Snoyman,

9
@Michael Snoyman: W takim razie dobry wybór! Prawdziwym żartem jest to, że opisywałem prawie najlepsze kodowanie w sposób, który brzmiał jak żart. Ha ha! Śmiejąc się aż do szubienicy ...
CA McCann

17

Oprócz tego, o czym wspomnieli już inni, czasami przydatne jest posiadanie pierwszorzędnych działań o skutkach ubocznych. Oto głupi przykład pokazujący pomysł:

f = sequence_ (reverse [print 1, print 2, print 3])

Ten przykład pokazuje, jak można budować obliczenia z efektami ubocznymi (w tym przykładzie print), a następnie umieścić je w strukturach danych lub manipulować nimi w inny sposób, zanim faktycznie je wykonasz.


1
Myślę, że kod JavaScript, który odpowiada to będzie: call = x => x(); sequence_ = xs => xs.forEach(call) ;print = console.log; f = () => sequence_([()=> print(1), () => print(2), () => print(3)].reverse()). Główną różnicą, jaką widzę, jest to, że potrzebujemy kilku dodatkowych () =>.
Hjulle
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.