Функциональное программирование

"Специфичный" подход

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

ЗначениеТипОписание
Аргумент "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 в некотором смысле.