Как работают функциональные языки программирования? - PullRequest
90 голосов
/ 01 мая 2010

Если функциональные языки программирования не могут сохранить какое-либо состояние, как они делают простые вещи, такие как чтение ввода от пользователя? Как они «хранят» входные данные (или хранят какие-либо данные по этому вопросу?)

Например: как бы эта простая вещь на языке C перешла на функциональный язык программирования, такой как Haskell?

#include<stdio.h>
int main() {
    int no;
    scanf("%d",&no);
    return 0;
}

(Мой вопрос был вдохновлен этим прекрасным сообщением: "Выполнение в царстве существительных" . Чтение этого документа дало мне некоторое понимание того, что такое объектно-ориентированное программирование, как его реализует Java в одном крайнем случае, и как контрастируют функциональные языки программирования.)

Ответы [ 9 ]

78 голосов
/ 02 мая 2010

Если функциональные языки программирования не могут сохранить какое-либо состояние, как они делают такие простые вещи, как чтение ввода от пользователя (я имею в виду, как они его «хранят») или сохранение каких-либо данных по этому вопросу?

Как вы поняли, функциональное программирование не имеет состояния - но это не значит, что оно не может хранить данные. Разница заключается в том, что если я напишу (Haskell) оператор в соответствии с

let x = func value 3.14 20 "random"
in ...

Я гарантирую, что значение x всегда одинаково в ...: ничто не может его изменить. Аналогичным образом, если у меня есть функция f :: String -> Integer (функция, принимающая строку и возвращающая целое число), я могу быть уверена, что f не изменит свой аргумент, не изменит какие-либо глобальные переменные, не запишет данные в файл и скоро. Как сказал sepp2k в комментарии выше, эта неизменяемость действительно полезна для рассуждений о программах: вы пишете функции, которые сворачивают, разбрасывают и деформируют ваши данные, возвращая новые копии, чтобы вы могли связать их вместе, и вы можете быть уверены, что ничего из этих вызовов функций можно сделать что-нибудь «вредное». Вы знаете, что x всегда x, и вам не нужно беспокоиться, что кто-то написал x := foo bar где-то между объявлением x и его использованием, потому что это невозможно.

А что если я захочу прочитать ввод от пользователя? Как сказал KennyTM, идея заключается в том, что нечистая функция - это чистая функция, которая передается всему миру в качестве аргумента и возвращает как свой результат, так и мир. Конечно, вы на самом деле не хотите этого делать: с одной стороны, это ужасно неуклюже, а с другой, что произойдет, если я снова использую тот же объект мира? Так что это как-то абстрагируется. Haskell обрабатывает это с типом IO:

main :: IO ()
main = do str <- getLine
          let no = fst . head $ reads str :: Integer
          ...

Это говорит нам о том, что main - это действие ввода-вывода, которое ничего не возвращает; выполнение этого действия - это то, что означает запуск программы на Haskell. Правило состоит в том, что типы ввода-вывода никогда не могут избежать действия ввода-вывода; в этом контексте мы вводим это действие, используя do. Таким образом, getLine возвращает IO String, о котором можно думать двумя способами: во-первых, как действие, которое при запуске создает строку; во-вторых, как строка, которая «испорчена» IO, так как она была получена нечистой. Первое более правильно, но второе может быть более полезным. <- извлекает String из IO String и сохраняет его в str & mdash; но так как мы находимся в операции ввода-вывода, нам придется свернуть его обратно, поэтому он не может " побег". Следующая строка пытается прочитать целое число (reads) и получает первое успешное совпадение (fst . head); это все чисто (без ввода-вывода), поэтому мы даем ему имя с let no = .... Затем мы можем использовать no и str в .... Таким образом, мы сохранили нечистые данные (от getLine до str) и чистые данные (let no = ...).

Этот механизм для работы с IO очень мощный: он позволяет вам отделить чистую алгоритмическую часть вашей программы от нечистой стороны взаимодействия с пользователем и применять ее на уровне типов. Ваша функция minimumSpanningTree не может изменить что-то еще в вашем коде или написать сообщение вашему пользователю, и так далее. Это безопасно.

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

Так что это, вероятно, было чем-то вроде обмана, добавляющего нечистоту в чистый Хаскелл. Но это не & mdash; получается, что мы можем полностью реализовать тип ввода-вывода в чистом Haskell (если нам дано RealWorld). Идея такова: IO-действие IO type аналогично функции RealWorld -> (type, RealWorld), которая принимает реальный мир и возвращает как объект типа type, так и измененный RealWorld. Затем мы определяем пару функций, чтобы мы могли использовать этот тип, не сходя с ума:

return :: a -> IO a
return a = \rw -> (a,rw)

(>>=) :: IO a -> (a -> IO b) -> IO b
ioa >>= fn = \rw -> let (a,rw') = ioa rw in fn a rw'

Первый позволяет нам говорить о действиях ввода-вывода, которые ничего не делают: return 3 - это действие ввода-вывода, которое не запрашивает реальный мир и просто возвращает 3. Оператор >>=, произносится как «bind», позволяет нам выполнять действия ввода-вывода. Он извлекает значение из действия ввода-вывода, передает его и реальный мир через функцию и возвращает полученное действие ввода-вывода. Обратите внимание, что >>= обеспечивает соблюдение нашего правила, согласно которому результаты действий ввода-вывода никогда не могут быть исключены.

Затем мы можем превратить вышеуказанный main в следующий обычный набор приложений функций:

main = getLine >>= \str -> let no = (fst . head $ reads str :: Integer) in ...

Скачки времени выполнения Haskell main с начального RealWorld, и мы готовы! Все чисто, просто причудливый синтаксис.

[ Edit: Как @Conal указывает , на самом деле это не то, что Haskell использует для IO. Эта модель ломается, если вы добавляете параллелизм или вообще любой способ изменить мир в середине действия ввода-вывода, поэтому для Haskell было бы невозможно использовать эту модель. Он точен только для последовательных вычислений. Таким образом, может случиться так, что IO Хаскелла является чем-то вроде уловки; даже если это не так, это, конечно, не совсем так элегантно. За наблюдением @Конала, посмотрите, что Саймон Пейтон-Джонс говорит в , что касается «Неловкого отряда» [pdf] , раздел 3.1; он представляет то, что может составить альтернативную модель по этим направлениям, но затем отбрасывает ее из-за ее сложности и принимает другую тактику.]

Опять же, это объясняет (в значительной степени), как IO и изменчивость в целом работают в Haskell; если это это все, что вы хотите знать, вы можете перестать читать здесь. Если вам нужна последняя доза теории, продолжайте читать & mdash; но помните, в этот момент мы очень далеко отошли от вашего вопроса!

Итак, последнее: получается такая структура - параметрический тип с return и >>= & mdash; очень общий; она называется монадой, и обозначения do, return и >>= работают с любым из них. Как вы видели здесь, монады не волшебны; все, что волшебно, - то, что do блоки превращаются в вызовы функций. Тип RealWorld - единственное место, где мы видим магию. Такие типы, как [], конструктор списка, также являются монадами и не имеют ничего общего с нечистым кодом.

Теперь вы знаете (почти) все о понятии монады (кроме нескольких законов, которые должны быть соблюдены, и формального математического определения), но вам не хватает интуиции. В сети есть смешное количество уроков по монадам; Мне нравится этот , но у вас есть варианты. Тем не менее, это, вероятно, не поможет вам ; Единственный реальный способ получить интуицию - это использовать их и прочитать несколько учебников в нужное время.

Однако, вам не нужна эта интуиция, чтобы понять IO . Понимание монад в полной общности - это глазурь на торте, но вы можете использовать IO прямо сейчас. Вы можете использовать его после того, как я показал вам первую main функцию. Вы даже можете обращаться с IO-кодом, как если бы он был на нечистом языке! Но помните, что есть базовое функциональное представление: никто не обманывает.

(PS: Извините за длину. Я пошел немного далеко.)

23 голосов
/ 02 мая 2010

Здесь много хороших ответов, но они длинные. Я постараюсь дать полезный короткий ответ:

  • Функциональные языки помещают состояние в те же места, что и C: в именованные переменные и в объекты, расположенные в куче. Различия в том, что:

    • В функциональном языке «переменная» получает свое начальное значение, когда она входит в область действия (через вызов функции или привязку let), и это значение не изменяется впоследствии . Точно так же объект, выделенный в куче, немедленно инициализируется со значениями всех его полей, которые после этого не изменяются.

    • «Изменения состояния» обрабатываются не путем изменения существующих переменных или объектов, а путем привязки новых переменных или выделения новых объектов.

  • IO работает хитростью. Вычисление побочных эффектов, которое генерирует строку, описывается функцией, которая принимает World в качестве аргумента и возвращает пару, содержащую строку и новый World. Мир включает в себя содержимое всех дисков, историю каждого сетевого пакета, когда-либо отправленного или полученного, цвет каждого пикселя на экране и тому подобное. Ключ к уловке в том, что доступ к Миру тщательно ограничен, так что

    • Ни одна программа не может сделать копию Мира (где бы вы ее поместили?)

    • Ни одна программа не может выбросить мир

    Использование этого трюка позволяет создать один уникальный мир, состояние которого развивается с течением времени. Языковая система времени исполнения, которая не написана на функциональном языке, реализует побочные вычисления, обновляя уникальный Мир вместо того, чтобы возвращать новый.

    Этот трюк прекрасно объясняется Саймоном Пейтоном Джонсом и Филом Уодлером в их знаковой статье «Императивное функциональное программирование» .

19 голосов
/ 02 мая 2010

Я прерываю комментарий от ответа на новый ответ, чтобы освободить место:

Я написал:

Насколько я могу судить, эта история IO (World -> (a,World)) является мифом применительно к Haskell, поскольку эта модель объясняет только чисто последовательные вычисления, в то время как тип IO Haskell включает параллелизм. Под «чисто последовательным» я подразумеваю, что даже мир (вселенная) не может изменяться между началом и концом императивного вычисления, кроме как из-за этого вычисления. Например, когда ваш компьютер пыхтит, ваш мозг и т.д. не могут. Параллелизм может быть обработан чем-то вроде World -> PowerSet [(a,World)], что допускает недетерминизм и чередование.

Норман написал:

@ Конал: Я думаю, что история IO довольно хорошо обобщает недетерминизм и чередование; если я правильно помню, в статье «Неудобный отряд» есть довольно хорошее объяснение. Но я не знаю хорошей статьи, которая бы четко объясняла истинный параллелизм.

@ Норман: Обобщает, в каком смысле? Я предполагаю, что денотационная модель / объяснение, которое обычно дается, World -> (a,World), не соответствует Haskell IO, потому что оно не учитывает недетерминированность и параллелизм. Возможно, есть более сложная модель, которая подходит, например, World -> PowerSet [(a,World)], но я не знаю, была ли такая модель разработана и показана адекватной и последовательной. Я лично сомневаюсь, что такого зверя можно найти, учитывая, что IO заполнен тысячами импортированных FFI императивных вызовов API. И как таковой, IO выполняет свое предназначение:

Открытая проблема: монада IO стала грехом Хаскелла. (Всякий раз, когда мы чего-то не понимаем, мы бросаем это в монаду IO.)

(Из выступления POPL Саймона Пиджея Ношение рубашки для волос Ношение рубашки для волос: ретроспектива на Haskell .)

В Разделе 3.1 из Занимаясь неуклюжим отрядом , Саймон указывает, что не работает с type IO a = World -> (a, World), включая «Подход не масштабируется, когда мы добавляем параллелизм». Затем он предлагает возможную альтернативную модель, а затем отказывается от попытки денациональных объяснений, говоря

Однако вместо этого мы примем операционную семантику, основанную на стандартных подходах к семантике исчислений процесса.

Эта неспособность найти точную и полезную денотационную модель лежит в основе того, почему я вижу Haskell IO как отклонение от духа и глубоких преимуществ того, что мы называем «функциональным программированием», или того, что Питер Лэнден более конкретно назвал « денотативное программирование ". Смотрите комментарии здесь.

17 голосов
/ 01 мая 2010

Функциональное программирование происходит от лямбда-исчисления. Если вы действительно хотите понять Функциональное программирование, проверьте http://worrydream.com/AlligatorEggs/

Это забавный способ выучить лямбда-исчисление и перенести вас в увлекательный мир функционального программирования!

Как знание лямбда-исчисления полезно в функциональном программировании.

Итак, Lambda Calculus является основой для многих реальных языков программирования, таких как Lisp, Scheme, ML, Haskell, ....

Предположим, что мы хотим описать функцию, которая добавляет три к любому входу, чтобы мы написали:

plus3 x = succ(succ(succ x)) 

Прочитать «плюс3 - это функция, которая при применении к любому числу х дает преемника преемника преемника х»

Обратите внимание, что функция, которая добавляет 3 к любому числу, не обязательно должна называться plus3; название «плюс3» - это просто удобное сокращение для обозначения этой функции

(plus3 x) (succ 0) ≡ ((λ x. (succ (succ (succ x)))) (succ 0))

Обратите внимание, что мы используем лямбда-символ для функции (я думаю, что это выглядит как Аллигатор, я думаю, именно отсюда и возникла идея о яйцах Аллигатора)

Лямбда-символ - Аллигатор (функция), а х - его цвет. Вы также можете рассматривать x как аргумент (функции лямбда-исчисления на самом деле предполагают только один аргумент), а остальное вы можете рассматривать как тело функции.

Теперь рассмотрим абстракцию:

g ≡ λ f. (f (f (succ 0)))

Аргумент f используется в позиции функции (при вызове). Мы называем функцию более высокого порядка, потому что она принимает другую функцию в качестве входа. Вы можете думать о вызовах других функций f как " eggs ". Теперь, взяв две функции или « Аллигаторы », которые мы создали, мы можем сделать что-то вроде этого:

(g plus3) = (λ f. (f (f (succ 0)))(λ x . (succ (succ (succ x)))) 
= ((λ x. (succ (succ (succ x)))((λ x. (succ (succ (succ x)))) (succ 0)))
 = ((λ x. (succ (succ (succ x)))) (succ (succ (succ (succ 0)))))
 = (succ (succ (succ (succ (succ (succ (succ 0)))))))

Если вы заметили, вы можете видеть, что наш Аллигатор λf съедает Аллигатора xx, а затем Аллигатор λx и умирает. Затем наш Х Аллигатор возрождается в яйцах Аллигатора. Затем процесс повторяется и λ x Аллигатор слева теперь съедает другой λ x Аллигатор справа.

Тогда вы можете использовать этот простой набор правил " Аллигаторы " поедание " Аллигаторы " для разработки грамматики и, таким образом, появились функциональные языки программирования!

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

14 голосов
/ 02 мая 2010

Техника обработки состояний в Хаскеле очень проста. И вам не нужно понимать монады, чтобы справиться с этим.

В языке программирования с состоянием у вас обычно где-то хранится какое-то значение, выполняется некоторый код, а затем у вас сохраняется новое значение. В императивных языках это состояние просто где-то «на заднем плане». На (чистом) функциональном языке вы делаете это явным образом, поэтому вы явно пишете функцию, которая преобразует состояние.

Так что вместо того, чтобы иметь какое-то состояние типа X, вы пишете функции, которые отображают X на X. Вот и все! Вы переключаетесь с размышлений о состоянии на размышления о том, какие операции вы хотите выполнять в этом состоянии. Затем вы можете связать эти функции вместе и объединить их различными способами для создания целых программ. Конечно, вы не ограничены просто отображением X в X. Вы можете написать функции, которые будут принимать различные комбинации данных в качестве входных данных и возвращать различные комбинации в конце.

Монады - один из многих инструментов, помогающих организовать это. Но монады на самом деле не являются решением проблемы. Решение состоит в том, чтобы думать о преобразованиях состояния вместо состояния.

Это также работает с I / O. По сути, происходит следующее: вместо того, чтобы получать ввод от пользователя с каким-то прямым эквивалентом scanf и сохранять его где-то, вы вместо этого пишете функцию, которая сообщает, что вы будете делать с результатом scanf, если бы у вас было это, а затем передать эту функцию в API ввода-вывода. Это именно то, что >>= делает, когда вы используете монаду IO в Haskell. Таким образом, вам никогда не нужно хранить результат ввода-вывода где-либо - вам просто нужно написать код, в котором будет указано, как вы хотите его преобразовать.

8 голосов
/ 01 мая 2010

(Некоторые функциональные языки допускают нечистые функции.)

Для чисто функциональных языков взаимодействие с реальным миром обычно включается в качестве одного из аргументов функции, например:

RealWorld pureScanf(RealWorld world, const char* format, ...);

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


Но чистая часть самого функционального языка уже завершена по Тьюрингу, что означает, что все, что можно сделать в C, также выполнимо в Haskell. Основное отличие от императивного языка состоит в том, чтобы вместо изменения состояний:

int compute_sum_of_squares (int min, int max) {
  int result = 0;
  for (int i = min; i < max; ++ i)
     result += i * i;  // modify "result" in place
  return result;
}

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

int compute_sum_of_squares (int min, int max) {
  if (min >= max)
    return 0;
  else
    return min * min + compute_sum_of_squares(min + 1, max);
}
3 голосов
/ 02 мая 2010
3 голосов
/ 01 мая 2010

Функциональный язык может сохранить состояние! Обычно они просто либо поощряют, либо заставляют вас быть откровенными в этом.

Например, проверьте Государственную Монаду Хаскелла .

1 голос
/ 01 мая 2010

Haskell:

main = do no <- readLn
          print (no + 1)

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

...