Помилкові уявлення про суто функціональні мови?


39

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

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

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

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

Щодо пункту 2, наскільки я знаю, ви можете представляти стан шляхом нарізування значень за допомогою декількох етапів обчислення (в Haskell, знову ж таки, за допомогою монадичних типів), але я не маю практичного досвіду цього, і моє розуміння досить розпливчасте.

Отже, чи є викладеними вище два твердження у будь-якому сенсі чи вони просто помилкові уявлення про суто функціональні мови? Якщо вони є хибними уявленнями, як вони виникли? Чи можете ви написати (можливо, невеликий) фрагмент коду, що ілюструє ідіоматичний спосіб Haskell (1) реалізувати побічні ефекти та (2) здійснити обчислення зі станом?


7
Я думаю, що більшість цього залежить від того, що ви визначаєте «чистою» функціональною мовою.
jk.

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

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

Відповіді:


26

Для цілей цієї відповіді я визначаю "суто функціональна мова", щоб означати функціональну мову, в якій функції референтно прозорі, тобто виклик однієї і тієї ж функції кілька разів з одними і тими ж аргументами завжди дасть однакові результати. Це, я вважаю, звичайне визначення суто функціональної мови.

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

Найпростішим способом досягти референтної прозорості було б дійсно заборонити побічні ефекти, і справді існують мови, в яких це відбувається (здебільшого конкретні доменні). Однак це, безумовно, не єдиний спосіб, а найбільш функціональні мови загальної цілі (Haskell, Clean, ...) дозволяють побічний ефект.

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

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

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

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

Знову ж таки, що суто функціональна мова може дуже добре забороняти стан, що змінюється, але, безумовно, можна бути чистим і все-таки мати стан, що змінюється, якщо реалізувати його так само, як я описав із побічними ефектами вище. Дійсно мінливий стан - це ще одна форма побічних ефектів.

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

Якщо вони є хибними уявленнями, як вони виникли?

Я думаю, що багато людей просто читають "функція повинна давати той самий результат, коли викликається з однаковими аргументами", і роблять висновок, що реалізувати щось на кшталт readLineабо код, який підтримує стан, що змінюється, неможливо . Тож вони просто не знають про "чити", які суто функціональні мови можуть використовувати для впровадження цих речей, не порушуючи еталонної прозорості.

Також стан, що змінюється, сильно перешкоджає функціональним мовам, тому не так вже й великий стрибок, щоб припустити, що він не дозволений у чисто функціональних мовах.

Чи можете ви написати (можливо, невеликий) фрагмент коду, що ілюструє ідіоматичний спосіб Haskell (1) реалізувати побічні ефекти та (2) здійснити обчислення зі станом?

Ось додаток у Псевдо-Хаскелл, який запитує у користувача ім’я та вітає його. Псевдо-Haskell - це мова, яку я щойно винайшов, і має систему вводу-виводу Haskell, але використовує більш звичайний синтаксис, більш описові назви функцій і не має doпримітки (оскільки це просто відволікатиметься від того, як саме працює монада 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і виробляє a MutableVariable<T>. Функція getValueприймає MutableVariableта повертає значення, IO<T>яке виробляє його поточне значення. setValueприймає a MutableVariable<T>і a Tі повертає значення, IO<void>яке встановлює значення. composeVoidMonadте саме, що, composeMonadза винятком того, що перший аргумент - це IOтой, що не дає значущого значення, а другий аргумент - інша монада, а не функція, яка повертає монаду.

У Haskell є якийсь синтаксичний цукор, який робить це ціле випробування менш болісним, але все одно очевидно, що стан, що змінюється, - це те, що мова насправді не хоче, щоб ти робив.


Прекрасна відповідь, уточнення багатьох ідей. Чи повинен останній рядок фрагмента коду використовувати ім’я counter, тобто increaseCounter(counter)?
Джорджіо

@Giorgio Так, так і повинно. Виправлено.
sepp2k

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

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

16

ІМХО вас бентежить, бо є різниця між чистою мовою та чистою функцією . Почнемо з функції. Функція є чистою, якщо вона (даючи один і той же вхід) завжди повертає одне і те ж значення і не спричиняє ніяких помітних побічних ефектів. Типовими прикладами є математичні функції, такі як f (x) = x * x. Тепер розглянемо реалізацію цієї функції. Це було б чисто в більшості мов, навіть тих, які взагалі не вважаються чистими функціональними мовами, наприклад, ML. Навіть метод Java або C ++ з такою поведінкою можна вважати чистим.

То що таке чиста мова? Власне кажучи, можна очікувати, що чиста мова не дозволяє вам виражати функції, які не є чистими. Назвемо це ідеалістичним визначенням чистої мови. Така поведінка вкрай бажана. Чому? Добре, що про програму, що складається лише з чистих функцій, полягає в тому, що ви можете замінити програму функції на її значення, не змінюючи значення програми. Це дуже легко міркує про програми, оскільки, дізнавшись результат, ви можете забути спосіб його обчислення. Чистота також може дозволяти компілятору проводити певні агресивні оптимізації.

То що робити, якщо вам потрібен якийсь внутрішній стан? Ви можете імітувати стан чистою мовою, просто додавши стан перед обчисленням як вхідний параметр і стан після обчислення як частину результату. Замість Int -> Boolвас вийде щось на кшталт Int -> State -> (Bool, State). Ви просто зробите залежність явною (що вважається хорошою практикою в будь-якій парадигмі програмування). До речі, існує монада, яка є особливо елегантним способом поєднання таких функцій, що імітують стан, у більші функції, що імітують стан. Таким чином ви точно зможете "підтримувати державу" чистою мовою. Але ви повинні зробити це явним.

Так це означає, що я можу взаємодіяти із зовнішнім? Адже корисна програма повинна взаємодіяти з реальним світом, щоб бути корисною. Але вхід і вихід, очевидно, не є чистими. Написання конкретного байта до певного файлу може бути чудовим вперше. Але виконання тієї самої операції вдруге може повернути помилку, оскільки диск заповнений. Очевидно, що не існує чистої мови (в ідеалістичному значенні), яка могла б записати у файл.

Тож ми стикаємося з дилемою. Ми хочемо здебільшого чистих функцій, але деякі побічні ефекти абсолютно необхідні, а ті, які не є чистими. Тепер реалістичним визначенням чистої мови було б те, що повинні бути певні засоби, щоб відокремити чисті частини від інших частин. Механізм повинен забезпечувати, щоб жодна нечиста робота не пробиралася в чисті частини.

У Haskell це робиться з типом IO. Ви не можете знищити результат IO (без небезпечних механізмів). Таким чином, ви можете обробляти результати IO лише за допомогою функцій, визначених самим модулем IO. На щастя, є дуже гнучкі комбінатори, які дозволяють отримувати результат IO і обробляти його у функції, доки ця функція повертає інший результат IO. Цей комбінатор називається зв'язувати (або >>=) і має тип IO a -> (a -> IO b) -> IO b. Якщо ви узагальнюєте це поняття, ви потрапляєте до класу monad, і 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? Функція, яка повертає значення типу IO Something? Якщо так, то цілком можливо двічі викликати функцію IO одним і тим же аргументом: putStrLn "hello" >> putStrLn "hello"- тут обидва виклики putStrLnмають один і той же аргумент. Звичайно, це не проблема, оскільки, як я вже говорив раніше, обидва дзвінки призведуть до однакового значення IO.
sepp2k

3
@scarfridge Оцінка writeFile "foo.txt" "bar"не може спричинити помилку, оскільки оцінка виклику функції не виконує дії. Якщо ви говорите, що в моєму попередньому прикладі версія з letлише однією можливістю спричинить збій IO, тоді як у версії без letдвох є помилка. Обидві версії мають дві можливості для відмови вводу-виводу. Оскільки letверсія оцінює виклик writeFileлише один раз, тоді як версія не letоцінює її двічі, ви можете бачити, що не важливо, як часто функція викликається. Важливо лише те, як часто виникає результат
sepp2k

6
@AidanCully "Менадний механізм" не минає навколо неявних параметрів. putStrLnФункція приймає тільки один аргумент, який має тип String. Якщо ви не вірите мені, подивіться на його тип: String -> IO (). Він, звичайно, не приймає жодних аргументів типу IO- він створює значення цього типу.
sepp2k
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.