Почему Haskell (иногда) называют «лучшим императивным языком»?

84

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

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

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

HVR
источник
3
donsbot.wordpress.com/2007/03/10/… <- Программируемая точка с запятой.
vivian
11
Эта цитата, вероятно, восходит к концу Введения о борьбе с неудобной командой: монадический ввод / вывод, параллелизм, исключения и вызовы на иностранных языках в Haskell, в котором говорится: «Короче говоря, Haskell - это лучший императивный язык программирования в мире».
Рассел О'Коннор
@Russel: спасибо за указание на наиболее вероятное происхождение (как кажется, самого SPJ) этого высказывания!
hvr
вы можете выполнять строго императивный объектно- ориентированный объект
Янус Троелсен

Ответы:

92

Считаю это полуправдой. 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 и C #.

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

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

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

  • «Вывести» поток строк
  • вернуть Int

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

На самом деле все дело в абстракционных способностях Haskell, которые делают его очень хорошим императивным языком.

Однако ложная половина - это синтаксис. Я считаю 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комбинатор другого стиля, он был бы чище. Но если вы зайдете в эту философию достаточно далеко (используя богатый набор комбинаторов, чтобы четко выразить себя), вы снова придете к функциональному программированию. Haskell в императивном стиле просто не «течет», как хорошо разработанный императивный язык, например, python.

В заключение, с синтаксической подтяжкой лица 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
Вы можете сделать шум IORef менее раздражающим, сделав больше абстракций.
август
1
@augustss, хм, мне это интересно. Вы имеете в виду больше абстракций на уровне предметной области или просто создавая более богатый императивный подъязык? »С первым я согласен, но мой разум связывает императивное программирование с низкой абстракцией (моя рабочая гипотеза заключается в том, что по мере увеличения абстракции стиль сходится на функциональном). Что касается последнего, мне было бы действительно интересно узнать, что вы имеете в виду, потому что я не могу придумать, как это сходит с
ума
2
@luqui Использование ST было бы хорошим примером для характеристики допустимых побочных эффектов. В качестве бонуса можно вернуться к чистым вычислениям из ST.
fuz 08
5
Использование Python в качестве сравнения не совсем справедливо - как вы говорите, он хорошо спроектирован, это один из самых синтаксически чистых императивных языков, с которыми я знаком. Такое же сравнение показало бы, что большинство императивных языков неудобно использовать в императивном стиле ... хотя, возможно, именно это вы имели в виду. ;]
CA McCann
5
Сноска к разговору для потомков: @augustss использует специальный полиморфизм, чтобы сделать IORefs неявным , или, по крайней мере, пытается это сделать, и ему мешают изменения в GHC. : [
CA McCann
22

Это не шутка, и я этому верю. Я постараюсь сделать это доступным для тех, кто не знает 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_функция используется очень часто и заменяет циклы for-each в других языках. Также есть функции, foreverкоторые действуют как while (true), и различные другие функции, которым можно передавать код и выполнять его по-разному. Таким образом, циклы на других языках заменяются этими управляющими функциями в Haskell (которые не являются специальными - вы можете очень легко определить их сами). В общем, это затрудняет неправильное выполнение условия цикла, так же, как циклы for-each труднее ошибиться, чем эквиваленты длинных итераторов (например, в Java) или циклы индексации массивов (например, в C).

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

    do line <- getLine
       putStrLn (foo line)
    

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

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

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

Нил Браун
источник
9
Я бы добавил строгую безопасность типов. Haskell позволяет компилятору устранить большой класс ошибок. После недавней работы над некоторым Java-кодом мне напомнили, насколько ужасны нулевые указатели и сколько ООП отсутствует без типов суммы.
Майкл Снойман 08
1
Спасибо за вашу разработку! Упомянутые вами преимущества, похоже, сводятся к тому, что Haskell рассматривает «императивные» эффекты как первоклассные объекты (которые, таким образом, комбинируются) вместе со способностью «содержать» эти эффекты в ограниченной области. Это адекватное сжатое резюме?
hvr 08
19
@Michael Snoyman: Но с типами сумм в ООП легко! Просто определите абстрактный класс, который представляет кодировку Чёрча вашего типа данных, подклассы для случаев, интерфейсы для классов, которые могут обрабатывать каждый случай, а затем передайте объекты, которые поддерживают каждый интерфейс, в объект суммы, используя полимофизм подтипов для потока управления (как вам следует). Не может быть проще. Почему вы ненавидите шаблоны проектирования?
CA McCann
9
@camccann Я знаю, что вы шутите, но по сути это то, что я реализовал в своем проекте.
Michael Snoyman
9
@Michael Snoyman: Тогда хороший выбор! Настоящая шутка заключается в том, что я описал лучшую кодировку таким образом, чтобы это звучало как шутка. Ха-ха! Смех до виселицы ...
CA McCann
17

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

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

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

тиббе
источник
1
Я думаю , что код - код , который соответствует этому будет: call = x => x(); sequence_ = xs => xs.forEach(call) ;print = console.log; f = () => sequence_([()=> print(1), () => print(2), () => print(3)].reverse()). Основное отличие, которое я вижу, в том, что нам нужно несколько дополнительных () =>.
Hjulle