Функциональное программирование
"Специфичный" подход
Давайте представим, что перед нами встала задача написать модуль, осуществляющий некоторое сложное вычисление. Поскольку само вычисление в данном контексте не важно, мы упростим его до деления двух чисел и опишем простой спецификацией:
Значение | Тип | Описание |
---|---|---|
Аргумент "x" | Число | Делимое |
Аргумент "y" | Число | Делитель |
Результат | Число | Частное |
Программа была успешно внедрена и долгое время всё было хорошо, пока не стали возникать ошибки. Проблему удалось быстро диагностировать и причиной оказалось деление на ноль. Настало время внести эту коррективу с спецификацию:
Значение | Тип | Описание |
---|---|---|
Аргумент "x" | Число | Делимое |
Аргумент "y" | Число | Делитель |
Результат | Число или ошибка деления на ноль | Частное |
Теперь мы знаем, что помимо числового решения при некоторых обстоятельствах мы можем получить побочный эффект.
Функциональный программирование в рамках "специфичного" подхода — это когда сигнатура функции выступает в роли спецификации. В нашем примере:
// Плохая спецификация
function Divide(x: int, y:int): int;
// Хорошая спецификация
function Divide(x: int, y: int): (int | DivideByZeroError);
С учётом классической модели, что Output = Program(Input), такой подход может специфицировать практически всё приложение целиком.
Теперь представим себя разработчиками, который использовали эту функцию деления в длинной цепочке вычислений:
a2 = div(a0, a1)
a3 = sqrt(a2)
a4 = 2*pi*a3
a5 = cos(a4)
Как только мы внесли изменение и добавили возможность возврата ошибки, компилятор отказывает собирать наш код. С точки зрения надёжности это хорошо, но неработающая программа - плохо.
Очевидно, что заставлять разработчиков всех остальных функций вносить изменения, обеспечивающие поддержку информации об ошибке нереально. Поэтому в индустрии было придумано самое простое решение - выбрасывать исключение. Это по сути паника локального масштаба. Мы останавливаем вычисление и надеемся, что кто-то "наверху" знает, что делать с этой ситуацией. Решение, мягко говоря, далекое от гибкости и универсальности.
Чтобы получить больше контроля, представим себе некоторый вычислитель, который знает о возможности существовании ошибки в результате и реализует простую стратегию. Мы ему последовательно передаем вычисления, которые хотим осуществить. Если он видит нормальный результат, то осуществляет вычисление, в противном случае также возвращет ошибку. То есть:
value = div(a0, a1)
value = calc(value, x => sqrt(x))
value = calc(value, x => 2*pi*x)
value = calc(value, x => cos(x))
Подобные вычислители получили в функциональном программировании название "монада". Первая строка реализует то, что принято называть pure-функцией монады,а остальные - bind.
"Грязные" эффекты
Часто можно услышать, что эффекты - это ввод/вывод, исключения и другие подобные аспекты. Но если представить себе программу, основная задача которой будет вывод на экран байт за байтом полученные данные из сетевого сокета, то несмотря на обилие "грязных" критериев, к ней сложно будет придраться.
Проблемы возникают, когда что-то начинает происходить скрыто. Например, мы занимаемся разработкой пользовательского интерфейса для программы деления описанной выше. И для нас начинает представлять проблему, если побочный эффект от деления на ноль не специфицирован.
TODO: Исключения могут быть, но это вопрос рантайма, который их будет обрабатывать? Так я могу более логично перейти к аспектному подходу.
Чистые функции и эффекты
TODO:Обычно определяют функциональные языки через чистоту функций. Во-первых, надо будет сделать сноску, что функциональный язык не тот, где всё чисто, а тот который может умело разделить основные задачи и эффекты.
TODO:Функция деления. Выглядит достаточно чистой, но что если будет деление на ноль. Может объединить функциональный подход со спецификациями. Смысл в том, что определив функцию деления как просто Int → Int мы специфицируем её не до конца. Даже командный процессор может не иметь доступа к консоли (пример с хостингом Powershell и не путать с консольным приложением).
TODO:Чистые данные - это данные, которые я хочу получить. Грязные данные содержат дополнительную информацию.
Монады
Представим себе существование некоторого объекта, скажем число 5. К нему может быть применена некоторая функция. Например f(x) = x*2. Дальше, большинство евангелистов предлагает преставить себе либо некоторый ящик, либо контекст. И речь идёт о том, что применение функции f теперь уже будет зависеть от контекста (возможно не стоит думать о применении функции к ящику и вообще о ящике).
Например, монада Maybe. Когда этот ящик открывается, там может оказаться либо 5, либо ничего. type Maybe a = Just a | Nothing. И вот здесь возникает вопрос, как применить функцию f к этом ящику? И вот здесь появляется функтор (fmap).
Решение проблемы побочных эффектов? Возможно речь идёт о любых попутных вычислениях? Например, была такая цитата: "Аппликативный функтор работает только с чистыми функциями, т. е. в случае аппликативного функтора вы можете совершать над элементами контейнера только операции без побочных эффектов (т.е. операции, которые никогда не могут вернуть сам контейнер). Монада же позволяет применять к значениям внутри контейнера любые операции, в том числе и с побочными эффектами (т.е. возвращающие контейнер того же рода).
Монады позволяют чистым функциональным языкам осуществлять императивные по сути операции, такие, как ввод-вывод и обработку исключений.
Простой пример монады - Option. Показать на примере простых математических операций.
Показать сравнение, как "протаскивает" значение классическом исключение и как это делает монада (Result или кто там).
Собрать как можно больше примеров монад: LINQ, генераторы списков, continuations, Maybe, Option, await/async/Task.
vs ООП
ФП может быть выгодно с точки зрения повторного использования. Инкапсуляция по сути говорит, что мы можем либо использовать класс целиком, либо не можем использовать вообще. Это - очевидный недостаток, лёгкая рекомпозиция практически невозможна. Может ООП даёт какие-то преимущества? Да, каждый метод неявно получает в качестве аргумента this и может обращаться к состоянию класса. Но можно без труда представить себе функции, которые содержат явный параметр этого состояния (а точнее, более гранулированный контракт, что повышает реюзинг) и некую обобщающую функцию, которая вернет их каррированные версии. Схематически выражаясь:
function objectEmulation() {
const state = {};
return {
create: curry(create, state),
read: curry(read, state),
update: curry(update, state)
}
}
Потребитель здесь получает специализированный контракт с полностью изолированным доступом к внутреннему состоянию.
Полиморфизм не составляет труда реализовать. Это, к слову, об упомянутой выше рекомпозиции. Можно сконструировать такой объект, что в нём будет заменена буквально одна функция. И этот же метод касается вопросов наследования. Можно скомбинировать самые разные функции не прибегая к строгим ограничениям наследования.
Вопросы
Предположим, что у нас есть функция вычисления факториала. Она реализована рекурсивно. Для ускорения работы факториала мы решили добавить мемоизацию в эту функцию. Как это лучше сделать если предположить, что у человека расширяющего исходную функцию нет доступа к её коду?
InBox
Монадические аспекты. Например, вычисление значений функции и вывод на экран, это функция, которая аспектами значения и с отображения на экране.
Bind описывает flow в некотором смысле.