Почему Хаскелл (иногда) называют "Лучшим императивным языком"?

(Я надеюсь, что этот вопрос по теме - я попытался найти ответ, но не нашел окончательного ответа. Если это происходит не по теме или уже ответили, пожалуйста, умерьте/удалите его.)

Я помню, что услышал/прочитал полушумный комментарий о том, что Haskell несколько раз был лучшим императивным языком, что, конечно же, звучит странно, поскольку Haskell обычно наиболее известен своими функциональными возможностями.

Итак, мой вопрос в том, какие качества/особенности (если они есть) Haskell дают основание оправдывать Haskell, считающегося лучшим императивным языком, или это скорее шутка?

Ответ 1

Я считаю это полуправдой. У Haskell есть удивительная способность абстрагироваться, и это включает в себя абстракцию над императивными идеями. Например, у Haskell нет встроенного императивного цикла while, но мы можем просто написать его, и теперь он делает:

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

Этот уровень абстракции трудно для многих императивных языков. Это можно сделать на императивных языках с закрытием; например. Python и С#.

Но Haskell также обладает (очень уникальной) способностью характеризовать допустимые побочные эффекты, используя классы Monad. Например, если у нас есть функция:

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

Это может быть "императивной" функцией, но мы знаем, что она может делать только две вещи:

  • "Вывести" поток строк
  • вернуть int

Он не может печатать на консоли или устанавливать сетевые подключения и т.д. В сочетании с возможностью абстракции вы можете писать функции, которые действуют на "любые вычисления, которые создают поток" и т.д.

Это действительно все о способности абстракции Хаскелла, которые делают его очень тонким императивным языком.

Однако ложная половина - это синтаксис. Я нахожу Haskell довольно многословным и неудобным для использования в императивном стиле. Ниже приведен пример вычисления в соответствии с императивным стилем с использованием вышеуказанного цикла while, который находит последний элемент связанного списка:

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

Все, что IORef мусор, двойное чтение, необходимо связать результат чтения, fmapping (<$>), чтобы работать с результатом встроенного вычисления... все это очень сложно выглядит. Это имеет большой смысл с точки зрения функциональности, но императивные языки, как правило, охватывают большинство этих деталей под ковриком, чтобы сделать их более удобными в использовании.

По общему признанию, возможно, если бы мы использовали другой комбинатор while -style, он был бы более чистым. Но если вы достаточно решите эту философию (используя богатый набор комбинаторов, чтобы четко выражать себя), вы снова получите функциональное программирование. Императорский стиль Haskell просто не "течет", как хорошо продуманный императивный язык, например. питон.

В заключение, с синтаксическим подтяжкой лица, Haskell вполне может быть лучшим императивным языком. Но, по характеру подтяжки лица, это заменило бы что-то внутренне красивое и настоящее с чем-то внешне красивым и поддельным.

ИЗМЕНИТЬ: Контраст lastElt с помощью этой транслитерации python:

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

Такое же количество строк, но каждая строка имеет немного меньше шума.


РЕДАКТИРОВАТЬ 2

Для чего это стоит, так выглядит чистая замена в Haskell:

lastElt = return . last

Что это. Или, если вы запретите мне использовать Prelude.last:

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

Или, если вы хотите, чтобы он работал над любой структурой Foldable и признал, что вам действительно не нужно IO, чтобы обрабатывать ошибки:

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

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

с Map, например:

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

Оператор (.) состав функций.

Ответ 2

Это не шутка, и я верю в это. Я постараюсь сделать это доступным для тех, кто не знает Haskell. Haskell использует do-notation (между прочим), чтобы вы могли написать императивный код (да, он использует монады, но не беспокойтесь об этом). Вот некоторые из преимуществ, которые дает вам Haskell:

  • Простое создание подпрограмм. Скажем, что я хочу, чтобы функция печатала значение для stdout и stderr. Я могу написать следующее, определяя подпрограмму одной короткой строкой:

    do let printBoth s = putStrLn s >> hPutStrLn stderr s
       printBoth "Hello"
       -- Some other code
       printBoth "Goodbye"
    
  • Простой код. Учитывая, что я написал выше, если теперь я хочу использовать функцию printBoth, чтобы распечатать весь список строк, что легко сделать, передав мою подпрограмму функции mapM_:

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

    Другой пример, хотя и не обязательно, сортирует. Скажем, вы хотите сортировать строки только по длине. Вы можете написать:

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

    Что даст вам [ "b", "cc", "aaaa" ]. (Вы можете написать его короче этого, тоже, но неважно, на данный момент.)

  • Простое повторное использование кода. Эта функция mapM_ используется много и заменяет каждый цикл на других языках. Там также forever, который действует как некоторое время (true) и различные другие функции, которые могут быть переданы кодом и выполнять его по-разному. Таким образом, петли на других языках заменяются этими функциями управления в Haskell (которые не являются особыми - вы можете легко определить их сами). В общем, это затрудняет неправильное получение условия цикла, так же как и для каждого цикла, сложнее ошибиться, чем эквиваленты длинного итератора (например, в Java) или циклы индексирования массивов (например, на C).

  • Связывание не выполняется. В принципе, вы можете присваивать переменной только один раз (скорее, как статическое присвоение). Это устраняет много путаницы в отношении возможных значений переменной в любой заданной точке (ее значение задается только на одной строке).
  • Содержащиеся побочные эффекты. Скажем, что я хочу прочитать строку из stdin и записать ее на stdout после применения к ней некоторой функции (назовем ее foo). Вы можете написать:

    do line <- getLine
       putStrLn (foo line)
    

    Я сразу понял, что foo не имеет никаких неожиданных побочных эффектов (таких как обновление глобальной переменной или освобождение памяти или что-то еще), потому что это тип должен быть String → String, что означает, что это чистая функция; независимо от того, какое значение я передаю, он должен возвращать тот же результат каждый раз, без побочных эффектов. Haskell прекрасно отделяет побочный код от чистого кода. В чем-то вроде C или даже Java это не очевидно (это свойство изменения метода getFoo()? Надеюсь, что нет, но это может произойти...).

  • Сбор мусора. В наши дни много языков - мусор, но стоит упомянуть: никаких проблем с распределением и освобождением памяти.

Вероятно, есть еще несколько преимуществ, но это те, которые приходят на ум.

Ответ 3

В дополнение к тому, что уже упоминалось ранее, иногда первостепенное значение имеет побочные действия. Вот глупый пример, чтобы показать идею:

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

В этом примере показано, как вы можете создавать вычисления с побочными эффектами (в этом примере print), а затем помещать в структуры данных или манипулировать ими другими способами, прежде чем выполнять их.