Нечистые действия в чистых функциях
Наибольшая трудность для понимания в функциональном программировании заключается в том, что чистые (как утверждается) функции
чистых языков Haskell и Clean могут быть (1) недетерминированными и (2) производить побочные эффекты.
Ведь чистые функции потому и чистые, что избавлены и от того, и от другого.
Нечистота в чистых функциях выглядит концептуальным противоречием.
Как если бы травоядные животные оставались травоядными, «инкапсулируя» в себе пищу хищников с помощью теорию категорий.
Обратимся к соответствующему разделу статьи «Чистая функция» английской Википедии (цитата 1):
Ввод-вывод по своей сути нечист: операции ввода подрывают ссылочную прозрачность, а операции вывода создают побочные эффекты.
Тем не менее ... функция может выполнять ввод или вывод и при этом оставаться чистой,
1) если последовательность операций на соответствующих устройствах ввода/вывода явно моделируется как аргумент и результат,
2) а операции ввода завершаются ошибкой, если входная последовательность не описывает операции, фактически выполненные с момента начала выполнения программы.
Второй пункт гарантирует, что единственная последовательность, используемая в качестве аргумента, должна изменяться с каждым действием ввода/вывода;
первый позволяет различным вызовам функции, выполняющей ввод-вывод, возвращать разные результаты из-за изменения аргументов последовательности.
Превращение неявной зависимости по побочному эффекту в явную зависимость по данным
Давайте осмыслим цитату. «Последовательность операций ... ввода/вывода явно моделируется как аргумент и результат». Примером этому может служить такой код:
(x1, s1) = f (x0, s0)
где
x0 — аргумент функции,
x1 — результат функции,
s0 — начальное состояние до побочного эффекта,
s1 — состояние после произведения побочного эффекта.
Это можно проиллюстрировать следующим рисунком:
Приведённая функция детерминирована: одним и тем же значениям параметра и состояния на входе соответствует одни и те же результат и новое состояние.
Побочный эффект не производится, но значение нового состояния передаётся в место вызова.
Здесь входными аргументами функции являются как собственно входное значение x0, так и состояние s0, передаваемое функции явно.
Функция явно принимает старое состояние и явно выдаёт новое.
При этом одинаковым сочетаниям x0 и s0 будет однозначно соответствовать одинаковое сочетание x1 и s1.
Состояние s0 не обязательно должно охватывать всё состояние.
Достаточно того, что оно будет включать в себя только изменяемые части состояния.
То же касается s1: оно описывает изменившуюся часть состояния.
Побочный эффект детерминирован, то есть одинаков для одинаковых состояний.
С этим, в принципе, можно согласиться, за исключением нюансов.
-
Если чистая функция возвращает новое состояние, то кто это состояние запишет? Должна существовать хотя бы одна нечистая функция, которая запишет изменённое состояние.
Если в чистом языке одна (как минимум) функция нечиста, то это противоречит тезису о полной чистоте языка.
-
Функции, возвращающей несколько значений, не соответствуют сложившейся в программировании традиции. Обычно возвращается либо одно скалярное значение, либо один кортеж.
Больших проблем это не вызывает — ни теоретических, ни практических, проработка этой темы велась.
Просто форма записи будет непривычной.
-
Когда изменяемое состояние помещается в скалярной величине или в небольшом контейнере, то это обычно не вызывает трудностей.
Но как изменить состояние в большой (или даже гигантской) базе данных, да так, чтобы это не вызвало всеобщего ступора?
Если состояние хранится в контейнере, то как вычленить его отдельные элементы — только те, которые подлежат изменению?
В функциональных языках предпочтение отдаётся не изменению старых значений, а созданию новых.
Это может привести к тому, что неизменяемые элементы контейнеров просто копируются,
тем самым заставляя процессор работать вхолостую, отчего кпд программы оказывается на уровне паровоза.
Хотя есть утверждения, что персистентность данных даёт возможность копировать только изменённые элементы контейнера.
Это в принципе решает проблему лишних копирований, но за счёт накладные расходов на поддержание персистентности.
Они заключаются в повышенном расходе как памяти, так и времени на её выделение в «куче».
-
«Вычислить» побочный эффект зачастую не представляется возможным.
Представим, что Ваша функция write(money) в приложении на смартфоне записывает некоторую сумму денег на банковский счёт.
Что произойдёт с этим счётом? Просто увеличится сумма счёта? Или деньги будут изъяты в пользу кредиторов?
Или они заблокируются до подтверждения законного происхождения денег?
Если ваша функция write(money) написана на императивном языке, до Вам и дела нет до того, что произойдёт на сервере банка.
Сервер просто вернёт функции код успешности транзакции.
Но если Вы пишете чистую функцию write(money), то ей надо будет передать старое состояние сервера банка (кто доверит Вам банковскую тайну?),
вычислить новое состояние (кто посвятит Вас в логику работы ПО банка?) и передать его серверу банка (ага, с дорисовкой шести нулей в графе «итого»).
-
Действие побочного эффекта может привносить непредсказуемость, т.е. недетерминизм. Тогда представленная на рисунке функция теряет право считаться чистой.
Программа на языке императивной парадигмы программирования может учесть эту непредсказуемость, ведь от неё никто не требует чистоты. А вот рассмотренная функция лишена такой возможности.
Схема работы функции, которая проиллюстрирована выше, может быть изменена.
Вычисление состояния s1 можно выполнить не в самой функции, а снаружи.
Функция возвращает не пару результат x1 и новое состояние s1,
а пару результат x1 и указатель на подпрограмму вычисления нового состояния,
которая принимает параметр s0 (старое состояние) и возвращает s1 (новое состояние).
Эта схема гибче, она позволяет не выполнять вычисление нового состояние напрямую, а поручить это вызывающей программе.
Или вообще поручить это некой подсистеме, отвечающей именно за это — за произведение побочных эффектов и вычисления новых состояний.
Даже если борьбу с побочными эффектами посчитать завершённой, то борьбу за чистоту функций нельзя считать законченной.
То есть победа над побочными эффектами — условие необходимое, но не достаточное.
Есть ещё целый класс функций, обладающих недетерминизмом, то есть их результат непредсказуем, что противоречит условию чистоты.
Как приручить недетерминированность?
Теперь возьмёмся за следующие части цитаты: «единственная последовательность, используемая в качестве аргумента, должна изменяться с каждым действием ввода/вывода»
и «позволяет различным вызовам функции ... возвращать разные результаты из-за изменения аргументов последовательности».
Как это понимать?
Самое логичный ответ даёт Александр Коновалов:
цепочка операций ввода/вывода будет записана как цепочка вызовов функций, где первая получает токен извне, вторая — токен, возвращённый предыдущим вызовом и т. д...
Примитивные операции ввода/вывода предоставляются стандартной библиотекой языка, все они в сигнатуре имеют токены,
и у программиста нет другой возможности осуществлять ввод-вывод, кроме как связывать эти функции в цепочки...
все операции ввода/вывода будут неизбежно связаны в цепочки, а функция main (или её аналог) будет иметь токен в качестве одного из параметров и должна будет его вернуть.
Цитата из документации Clean:
разрешено деструктивно обновлять уникальный объект без каких-либо последствий для прозрачности ссылок.
Хотелось бы разобрать это противоречие на примере языка Clean. Потому что если с Haskell на заявление «Я не понимаю, почему функции с монадой IO чисты»
можно задать встречные вопросы «А ты выучил теорию категорий? А монадические законы? Нет?! Иди учи!», то с Clean всё на виду, как на ладони.
Типы с гарантией уникальности в языке Clean выглядят концептуально проще и доступнее.
Они позволяют обходиться без притягивания дополнительных сущностей, польза которых не всегда очевидна.
Ответом на вопрос «Как в чистых функциях и языках выполняются нечистые действия» может быть такая аналогия.
Очевидно всем, что программы так или иначе управляют выделением и освобождением памяти.
Но есть языки, в которых это управление совершенно неявное, оно выполняется автоматически.
Программист может быть даже не в курсе, как это происходит, потому что подробности спрятаны под капотом.
Программисту не приходится вызывать ни malloc(), ни free(), ни их аналоги.
Их вставляет компилятор, делая управление памятью автоматическим.
Так же и с нечистыми действиями.
Чистые функции возвращают кортеж, содержащий как собственно результат, так и указатель на подпрограмму, выполняющую нечистые действия.
Компилятор сам вставляет в нужные места вызов этой подпрограммы.
А вот в какое именно — это определяют монады или типы с гарантией уникальности.
В итоге все функции могут быть чисты, а вызов подпрограмм с нечистыми действиями спрятан под капотом.
Надводная, публичная часть айсберга состоит из чистых функций.
Императивная же часть, производящая побочные эффекты и имеющая недетерминизм, является подводной.
Она не видна, недоступна напрямую, «инкапсулирована».
Опубликовано: 2022.03.16, последняя правка: 2022.03.17 15:02
Добавить свой отзыв
Написать автору можно на электронную почту mail(аt)compiler.su
|