Заблуждения о чисто функциональных языках?


39

Я часто сталкиваюсь со следующими утверждениями / аргументами:

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

Я не специалист по функциональным языкам, но вот что я понял по этим темам до сих пор.

Что касается пункта 1, вы можете взаимодействовать со средой на чисто функциональных языках, но вы должны явно пометить код (функции), который вводит побочные эффекты (например, в Haskell с помощью монадических типов). Кроме того, насколько я знаю, вычисление с помощью побочных эффектов (деструктивное обновление данных) также должно быть возможным (с использованием монадических типов?), Даже если это не предпочтительный способ работы.

Что касается пункта 2, насколько я знаю, вы можете представлять состояние, пропуская значения через несколько этапов вычисления (опять же, в Haskell, используя монадические типы), но у меня нет практического опыта в этом, и мое понимание довольно расплывчато.

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


7
Я думаю, что большая часть этого зависит от того, что вы определяете как «чистый» функциональный язык.
JK.

@jk: Чтобы избежать проблемы определения «чистых» функциональных языков, примите чистоту в смысле Хаскелла (который четко определен). При каких условиях функциональный язык может считаться «чистым», он может стать темой будущего вопроса.
Джорджио

Оба ответа содержат много разъясняющих идей, и мне было трудно выбрать, какой из них принять. Я решил принять ответ sepp2k из-за дополнительных примеров псевдокода.
Джорджио

Ответы:


26

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

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

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

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

Что касается пункта 1, вы можете взаимодействовать со средой на чисто функциональных языках, но вы должны явно пометить код (функции), который их вводит (например, в Haskell с помощью монадических типов).

Это немного упрощает. Просто наличие системы, в которой функции с побочными эффектами должны быть помечены как таковые (аналогично const -корректности в C ++, но с общими побочными эффектами), недостаточно для обеспечения ссылочной прозрачности. Вы должны убедиться, что программа никогда не сможет вызывать функцию несколько раз с одинаковыми аргументами и получать разные результаты. Вы можете сделать это, сделав такие вещи, какreadLineбыть чем-то, что не является функцией (это то, что Haskell делает с монадой ввода-вывода), или вы можете лишить возможности вызывать побочные функции несколько раз с одним и тем же аргументом (это то, что делает Clean). В последнем случае компилятор будет гарантировать, что каждый раз, когда вы вызываете побочную функцию, вы делаете это со свежим аргументом, и он отклоняет любую программу, в которой вы передаете один и тот же аргумент побочной функции дважды.

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

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

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

Если это неправильные представления, как они возникли?

Я думаю, что многие люди просто читают «функция должна вызывать одинаковый результат при вызове с одинаковыми аргументами» и делают из этого вывод, что невозможно реализовать что-то подобное readLineили код, который поддерживает изменяемое состояние. Таким образом, они просто не знают о «читах», которые могут использовать чисто функциональные языки, чтобы представить эти вещи без нарушения ссылочной прозрачности.

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

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

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

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

Подсказка в том, что readLineэто значение типа IO<String>и composeMonadфункция, которая принимает аргумент типа IO<T>(для некоторого типа T) и другой аргумент, который является функцией, которая принимает аргумент типа Tи возвращает значение типа IO<U>(для некоторого типа U). printэто функция, которая принимает строку и возвращает значение типа IO<void>.

Значение типа IO<A>- это значение, которое «кодирует» данное действие, которое создает значение типа A. composeMonad(m, f)создает новое IOзначение, которое кодирует действие, за mкоторым следует действие f(x), где x- значение, полученное при выполнении действия m.

Изменяемое состояние будет выглядеть так:

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

Вот mutableVariableфункция, которая принимает значение любого типа Tи создает MutableVariable<T>. Функция getValueпринимает MutableVariableи возвращает значение, IO<T>которое выдает текущее значение. setValueпринимает a MutableVariable<T>и a Tи возвращает значение, IO<void>которое устанавливает значение. composeVoidMonadто же самое, composeMonadза исключением того, что первый аргумент является аргументом IO, который не дает разумного значения, а второй аргумент является другой монадой, а не функцией, которая возвращает монаду.

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


Отличный ответ, проясняющий множество идей. Если последняя строка фрагмента кода использовать имя counter, то есть increaseCounter(counter)?
Джорджио

@ Джорджио Да, так и должно быть. Исправлена.
сентября

1
@Giorgio Одна вещь, которую я забыл явно упомянуть в своем посте, это то, что возвращаемое IO-действие mainбудет тем, которое фактически будет выполнено. За исключением возврата ввода-вывода main, невозможно выполнить какие-либо IOдействия (без использования ужасно злых функций, имеющих unsafeсвое имя).
sepp2k

ХОРОШО. шарфридж также упомянул разрушающие IOценности. Я не понял, ссылается ли он на сопоставление с образцом, то есть на тот факт, что вы можете деконструировать значения алгебраического типа данных, но нельзя использовать сопоставление с образцом, чтобы сделать это со IOзначениями.
Джорджио

16

ИМХО, вы сбиты с толку, потому что есть разница между чистым языком и чистой функцией . Давайте начнем с функции. Функция является чистой, если она (при одинаковых входных данных) всегда возвращает одно и то же значение и не вызывает никаких наблюдаемых побочных эффектов. Типичными примерами являются математические функции, такие как f (x) = x * x. Теперь рассмотрим реализацию этой функции. Это было бы чисто в большинстве языков, даже тех, которые обычно не считаются чисто функциональными языками, например, ML. Даже метод Java или C ++ с таким поведением можно считать чистым.

Так что же такое чистый язык? Строго говоря, можно ожидать, что чистый язык не позволяет выражать функции, которые не являются чистыми. Давайте назовем это идеалистическим определением чистого языка. Такое поведение очень желательно. Зачем? Хорошо, что в программе, состоящей только из чистых функций, вы можете заменить приложение функции его значением, не меняя смысла программы. Это позволяет очень легко рассуждать о программах, потому что когда вы знаете результат, вы можете забыть, как он был вычислен. Чистота может также позволить компилятору выполнять определенные агрессивные оптимизации.

Так что, если вам нужно внутреннее состояние? Вы можете имитировать состояние на чистом языке, просто добавляя состояние перед вычислением в качестве входного параметра и состояние после вычисления как часть результата. Вместо Int -> Boolтебя получается что-то вроде Int -> State -> (Bool, State). Вы просто делаете зависимость явной (что считается хорошей практикой в ​​любой парадигме программирования). Кстати, есть монада, которая является особенно элегантным способом объединить такие функции имитации состояния в большие функции имитации состояния. Таким образом, вы определенно можете «поддерживать состояние» на чистом языке. Но вы должны сделать это явно.

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

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

В Haskell это делается с помощью типа IO. Вы не можете уничтожить результат ввода-вывода (без небезопасных механизмов). Таким образом, вы можете обрабатывать результаты ввода-вывода только с помощью функций, определенных в самом модуле ввода-вывода. К счастью, есть очень гибкие комбинаторы, которые позволяют вам брать результат ввода-вывода и обрабатывать его в функции, пока эта функция возвращает другой результат ввода-вывода. Этот комбинатор называется bind (или >>=) и имеет тип IO a -> (a -> IO b) -> IO b. Если вы обобщите эту концепцию, вы придете к классу монад, и IO окажется его примером.


4
Я действительно не вижу, как Haskell (игнорируя любую функцию с unsafeее именем) не соответствует вашему идеалистическому определению. В Haskell нет нечистых функций (опять игнорируем unsafePerformIOи ко.).
sepp2k

4
readFileи writeFileвсегда будет возвращать одно и то же IOзначение, учитывая одинаковые аргументы. Так, например, два фрагмента кода let x = writeFile "foo.txt" "bar" in x >> xи writeFile "foo.txt" "bar" >> writeFile "foo.txt" "bar"будут делать то же самое.
sepp2k

3
@AidanCully Что вы подразумеваете под "функцией ввода-вывода"? Функция, которая возвращает значение типа IO Something? Если это так, то вполне возможно дважды вызвать функцию ввода-вывода с одним и тем же аргументом: putStrLn "hello" >> putStrLn "hello"- здесь оба вызова putStrLnимеют один и тот же аргумент. Конечно, это не проблема, потому что, как я сказал ранее, оба вызова приведут к одному и тому же значению ввода-вывода.
sepp2k

3
@scarfridge Оценка writeFile "foo.txt" "bar"не может вызвать ошибку, потому что оценка вызова функции не выполняет действие. Если вы говорите, что в моем предыдущем примере у версии с letесть только одна возможность вызвать сбой ввода-вывода, а у версии без letдвух - вы ошибаетесь. Обе версии имеют две возможности для сбоя ввода-вывода. Поскольку letверсия оценивает вызов writeFileтолько один раз, а версия без letего оценки дважды, вы можете видеть, что не имеет значения, как часто вызывается функция.
Имеет

6
@AidanCully «Механизм монады» не передает неявные параметры. putStrLnФункция принимает только один аргумент, который имеет тип String. Если вы не верите мне, посмотрите на его тип: String -> IO (). Он, конечно, не принимает аргументов типа IO- он генерирует значение этого типа.
sepp2k
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.