Почему функциональное программирование такое сложное
Я несколько раз начинал читать статьи и серии «Введение в функциональное программирование», «Введение в Теорию Категорий» и даже «Введение в Лямбда Исчисление». Причем и русском, и на английском. Каждый раз впечатление было очень сходным: во-первых, много новых непонятных слов; во-вторых, много новых определений, которые возникают из ниоткуда; в-третьих, совершенно непонятно, как это использовать.
Самым непонятным и зубодробительным оказалось, наверное, Теория Категорий. Я освоился в ней только с третьего подхода. В первые два раза я честно все прочитал, кажется понял, но т.к. никакой связки с реальной жизнью она не имела, то спустя неделю она благополучно полностью выветривалась.
Попытки использовать как-то в работе изученные концепции разбивались о полное непонимание, как применить полученное глубокое знание. Ведь, напомню, что парадигму ФП (где-то удобнее, где-то не очень, но) можно использовать практически в любом ЯП, совсем необязательно для этого изучать условный Хаскель.
Кому эта статья
Эта статья для программистов, давно желавших понять Функциональное Программирование, пытавшихся что-то почитать на эту тему и упершихся в стену «да что, это блин за хрень такая, и зачем все так усложнять?!». Поэтому в этой статье я попытаюсь ответить на вопрос «зачем они это придумали», не сильно ударяясь в технические дебри. Я сегодня побуду таким «Робертом Киосаки от функционального программирования», который не столько учит вас финансовой функциональной грамотности, сколько мотивирует ее в себе развивать.
Дисклеймер
Я не претендую на звание эксперта в функциональном программировании или Теории Категорий. Далее в статье я излагаю довольно упрощенный и частный взгляд на довольно нетривиальные вещи. Прошу отнестись с пониманием к неточностям и ошибкам, ведь даже «истина, высказанная словами — есть ложь». Тем не менее я буду рад уточнениям и исправлениям, отправленным в личку.
Зачем нам Функциональное Программирование?
Изучение ФП делает разработчика профессиональнее. Я даже не буду приводить ссылки на пруфы, потому что в 2020 это уже просто незыблемая истина.
Функциональные концепции входят во все большее количество современных языков, а некоторые языки так вообще создаются сразу функциональными.
Тем не менее уровень входа в ФЯП крайне высок. Иногда настолько высок, что вполне состоявшиеся разработчики с многолетним и вполне успешным опытом не могут освоить его принципы, даже честно пытаясь. Иногда непонимание концепций ФП приводит к анекдотичному причислению некоторых ЯП к числу функциональный лишь на том основании, что в них есть функция map (). Разработчики могут искренне заблуждаться, считая, что они уже освоили ФП.
Что я понял по результатам моих попыток.
Есть общая беда всех курсов из разряда «Введение в …» (даже со смешными картинками) — они дают пачку базовых определений, постепенно повышая сложность. «Почему это беда?», спросите вы. Отвечу аналогией: когда вы учите двухлетнего ребенка отличать круглое от квадратного, вы не даете ему геометрической аксиоматики, «что такое прямая», «что такое окружность» и т.п. плавно подводя к определению «шар» и «куб». Вы просто даете ему кубики и шарики и доску с дырками, в которую их надо вставить. Только значительно позже, уже в средних классах, он узнает их формальные определения. Конечно, программист с опытом — не двухлетний ребенок. Но встроенная в каждого человека нейросеть эволюционировала, как инструмент обобщения разрозненных примеров в общий абстрактный принцип, а совсем не как инструмент, получающий готовые абстрактные принципы. Обучение на примерах всегда идет быстрее нежели сухое перечисление определений.
Анекдот:
— Пап, а как пишется восьмерка?— Как «бесконечность», повернутая на пи-пополам».
Аналогично, при чтении очередного определения «монады» через функтор, у меня в голове возникала только одна мысль: вроде все понятно, но непонятно НАХРЕНА. Ощущения примерно соответствуют этой картинке:
Какую задачу они пытались решить, если в условной Java и так все работает без этих ваших всяких монад. Между тем, как будет показано ниже, за каждой очередной функциональной абракадаброй стоит вполне реальная решенная задача, понятная любому программисту с 2–3 летним стажем.
Сначала немного бла-бла
Так уж исторически сложилось, что основная терминология ФП пришла из мира математики. Причем ОЧЕНЬ абстрактной математики. Теория Категорий, разработанная в 1940-х годах — это настолько абстрактная теория, что она полностью оторвана не только от реального мира, но и от многих разделов «обычной» математики. По своей абстрактности она близка к формальной логике «на стероидах».
Хорошая новость состоит в том, что для того чтобы понять ФП совсем не обязательно начинать (или вообще знать) Теорию Категорий. Я советую сначала разобраться в практике ФП, а потом уже копать в сторону «корней» теоретической основы.
Плохая новость состоит в том, что с этой оторванной от реальности терминологией придется смириться и привыкнуть. Поэтому я буду все время смешивать абстрактные термины с общечеловеческими чтобы вы к ним привыкали.
Абстрактность
Любую архитектурную проблему можно решить введением дополнительного слоя абстракции, кроме проблемы излишнего количества слоев абстракции.
© бессмертное
Следуя подходу Теории Категорий «обобщай пока есть что обобщать», в ФП обобщают все что можно. Если что-то можно обобщить или абстрагировать — оно будет обобщено и абстрагировано. В итоге все приходит к условной абсолютно абстрактной «монаде», которая как «Многоликий Будда» не может быть описана одним предложением (хотя ниже я попытаюсь).
Следование этому пути приводит к полному исключению не только дублирования кода, но и дублирования алгоритмов и даже примитивов языка.
- Если ФП-программист видит две функции с циклами внутри, то он пишет одну библиотечную функцию, которая реализует абстракцию «цикл» с параметром «тело цикла». За одно делая ее, таким образом, чтобы вложенные циклы выглядели как параметр для параметра «тело». И т.п.
- Если ФП-программист видит два оператора
if
, то он пишет функцию, которая принимает предикат и возращает монаду, превращая весь код в цепочку вызовов функцийmap
.
Если у тебя из инструментов есть только функции, то рано или поздно все начинает казаться монадой.
© мое.
Декларативность
Сложности с пониманием ФП у «обычного разработчика» во многом обусловлены также необходимостью смены парадигмы императивного программирования на декларативное.
Дело в том, что исторически прямо со времен зарождения программирования все обучение новых программистов ведется на алгоритмах последовательного выполнения инструкций для получения некоего результата — императивном программировании. Императивно устроены большинство и старых, и даже новых ЯП. В противоположность Императивному было разработано Декларативное Программирование, к которому относится в том числе ФП. Не вводя абстрактных определений, просто приведу сравнительную таблицу двух подходов на житейских примерах:
Видно, что наши «программы» отличаются «точкой зрения на проблему». Императивный подход — постепенно конструируем результат от простого к сложному. Первая написанная функция императивного программиста будет »Чистить(Овощ) = …
». Декларативный — наоборот: начинаем с самого конца, постепенно декомпозируя задачу на более мелкие. Первая написанная функция будет называться »ГотовыйБорщ = …
».
Но есть еще более существенная разница. Дело в том, что программа по поиску клада выполнится корректно только для конкретной начальной точки (хоть метр в сторону — и все было напрасно). А если в процессе варки борща, окажется что нет свеклы, то мало того, что борщ не сварится, так еще и зря пропадут уже порезанные продукты. А при попытке перезапуска процесса варки морковь окажется порезанной дважды. Поэтому основная проблема императивного подхода — большая чувствительность к начальному состоянию и прочим глобальным переменным в процессе исполнения. Что приходится компенсировать бесконечными if-ами, assert-ами, и обмазывать толстым слоев контрактов и тестов.
Может показаться, что я «натягиваю сову на глобус», выставляя декларативное программирование святым граалем. Но истина в том, что за все приходится платить. Основной недостаток Декларативного подхода — это необходимость прописывания всех (вот прям вообще всех, Наташ!) ограничений на все данные на всех этапах обработки — еще до запуска, что повышает сложность на этапе программирования, но отплачивает сполна в процессе работы. Этот полу-магический эффект, «если скомпилировалось, значит работает», замечают практически все, кто изучает ФП. Плюс функциональный код легко распараллеливается.
Обработка всех случаев и протаскивание контекста на все уровни создавало бы дикое количество бойлер-плейта в реальных программах. ФП научилось бороться с этим явлением, используя монады, о которых позже.
Чистая функциональная программа — это не поток исполнения (control flow), а поток данных (data flow), сопровождаемый монадическими Эффектами (о них чуть позже). Программа на чистом ФЯП — это такая многослойная матрешка, которая не делает ничего, пока не начнешь ее раскрывать слой за слоем. В процессе чего из нее иногда будут «вываливаться» Эффекты, сигнализирующие, что что-то там фактически программой было сделано.
Кроме того, декларативный подход «сверху-вниз» позволяет создавать библиотеки невероятной мощности: библиотека выкапывания всего и везде, библиотека варки любых блюд и т.п., которые дают сразу наборы функций из «верхней части» алгоритма (типа «варить до готовности»). Остаётся только дописать «нижнюю» половину — в каком порядке складывать в кастрюлю. Библиотека, работающая с монадами, работает с ЛЮБЫМИ монадами (которые удовлетворяют специальным «законам», общим для всех монад). Это обобщенное программирование, возведенное в Абсолют.
Отмечу также, что приведенные выше примеры — это «сферические кони». На практике даже в императивных ЯП программа пишется, начиная с функции main. И часто дальнейшее проектирование тоже происходит «по-декомпозиционному», т.е. сверху вниз. А опытный программист может написать все в декларативном стиле даже на «голых сях». С другой стороны, и в ФЯП программа, будучи декларативной по своей природе будет все равно чаще всего написана, как цепочка последовательных преобразований данных от начального состояния к конечному. Поэтому все же стоит вспомнить избитый принцип «императивный код определяет, что надо сделать, а декларативный код — что надо получить».
Декларативный язык всегда требует исполнительного механизма (иногда довольно сложного), способного из описания «что надо» понять, «как» это получить. А декларативная программа должна содержать достаточно подробное описание чтобы исполнительный механизм мог понять, как это сделать. Из этого следует второй большой недостаток ФП: не всегда удается получить производительный код, напрямую используя выразительные декларативные конструкции. В таких случаях приходится вспоминать, что там под капотом, и давать компилятору подсказки по оптимизации либо просто отказываться от красивостей в пользу менее красивого, но более производительного кода.
Примерами декларативных систем и их исполнительных механизмов являются:
Прошу к столу
Теперь наконец попробуем разобраться в терминах Теории Категорий на понятных условному сишнику/джависту примерах.
Категория — любой примитивный или составной тип данных: строка, число, пара строка-число (кортеж), массив чисел, тип функций (например, функция
IntToStr
имеет типInteger -> String
). Функциональные типы (т.е. сигнатуры) — полноценные типы. Можно из них тоже собрать кортеж или сложить в массив. Параметры обобщенных типов (те, которые с дженериками, т.е. вArray[Int]
, например,Array
— это обобщённый тип, аInt
— это его параметр) еще могут быть Ковариантными/Контравариантными/Инвариантными. Эта тема стоит отдельной статьи.Морфизм — это любая функция, преобразующая один тип в другой. Вот
IntToStr
— это вполне себе морфизм. Итого: видим «морфизм» — читаем «функция конвертации». «Эндоморфизм» — это морфизм внутри категории, т.е. преобразование типа в самого себя. Функция «синус» вполне себе Эндоморфизм из КатегорииDouble
в нее же, хотя и крайне примитивный. Более сложный пример морфизма: преобразователь пары строк (username, password) в объект сессии.«Ее Величество» Монада — это простой и банальный контейнер. Ее основная цель — обрабатывать данные в контейнере, не вынимая их наружу. Для этого к ней прицепили парочку функций (
map
). Например, если у нас есть монада-список (массив) чисел, то преобразование их в строки можно сделать прямо в массиве, сразу получив на выходе готовый массив строк, не заморачиваясь с циклами, созданием новых массивов и т.п.- Важное уточнение: когда я говорю «превратили числа в строки, не доставая из контейнера», я не имею в виду, что поменялось содержимое самого массива. Исходный массив (экземпляр) остается неизменным, но вызвав преобразование, мы получим второй массив (или дерево, или любой другой контейнер) идентичной структуры, только уже содержащий строки.
- Но это только половина правды. Вторая половина состоит в том, что когда вы получили указатель на массив строк, никакого массива еще нет. Все вычисления «ленивые». Это означает, что пока вы не попытаетесь прочитать что-то из этого «массива» (который на самом деле просто аналог сишного
Handle
) ничего выполнено и сконвертировано не будет. Поэтому вы можете строить цепочки конверсий, которые мгновенно возвращают управление (потому что ничего не делают), и в конце, когда вам понадобится что-то достать из конечного контейнера, только тогда вся цепочка и раскрутится в последовательность вызовов конкретныхInToStr
и им подобным. - В хаскеле функция такой обработки называется
bind
(>>=
), что имеет корни в Теории Категорий. Ведь bind — это «связывание», т.е. функцияbind
фактически создает ребро в графе категорий (связывает узлы). В большинстве языков «здорового человека» эта функция называетсяmap()
(«отобразить», «поставить в соответствие»). По мне, логичнее было бы ее назватьcast()
(«снять слепок», «преобразовать»), но меня почему-то не спросили. - Есть распространённая монада
Option
/Maybe
, смысл которой в том, чтобы хранить одно единственное значение. Или не хранить. Например, мы могли бы сделать функциюStrToIntOption
, которая бы принимала строку и возвращалаOption[Int]
, т.е. такую монаду (контейнер), в которой либо лежало бы число (если строка в него парсится), либо не содержало бы ничего. С таким контейнером мы можем делать разные вещи, даже не проверяя, что в нем лежит. Например, можем умножить его содержимое на »2», взять синус, вывести на экран или отправить по сети. Для этого мы используем наш методmap()
, передав в него функцию, которая должна сделать что-то полезное. Но фактически выполнена эта функция будет только, если в контейнере значение правда лежит (число распарсилось). Если в контейнере ничего нет, то ничего и не произойдет, ничего не умножится, ничего не отправится. - А вообще полезных монад люди придумали множество. Но все они несут один простой смысл, который описан выше. В любой большой системе можно наковырять с десяток служебных типов, которые можно было бы заменить монадическим типом. Монада-контейнер может накапливать в себе любой контекст, произошедшие ошибки, логирование и что угодно еще, не останавливая поток обработки и не засоряя код ненужным бойлер-плейтом. С помощью монад довольно элегантно решается большинство задач Аспектно-ориентированного программирования.
- Мощь и удобство функции
map()
оказались настолько велики, что ее добавили к себе многие современные языки, далекие от чистого ФП.
Функтор — обработчик данных в контейнере-монаде. Функтор без монады — деньги на ветер.
- Важное пояснение: в большинстве статей про ФП вам расскажут про функтор до монады, а потом, давая определение монады расскажут, что она тоже является функтором. После этого в голове обычно вообще все запутывается. Поэтому давайте договоримся, что вы пока забудете то, что сейчас прочитали в этом абзаце и просто продолжите читать.
- Функтор — это та самая упомянутая выше функция
map
/bind
. Смысл названия «злобный Функтор»: «функция над функциями», но он не отражает ее сути. Суть же — взять какой-нибудь морфизм (т.е. преобразователь типов) и применить его прямо внутри монады (контейнера). Т.е. Функтор — это Морфизм (преобразование) в Монаде (контейнере). Функтор выглядит со стороны это как будто вызвали функцию (map
), передав в качестве параметра другую функцию (IntToStr
), а в результате она вернет нам такой же массив, только уже со строками вместо чисел. - Теперь вернемся к теме «монада является функтором». На практике это означает, что в классе монады есть метод map. Все. Но по смыслу монада — это контейнер, а функтор — это оператор преобразования содержимого. Взболтайте, не смешивайте!
- У Функтора есть двоюродный брат — Аппликативный функтор.
Аппликативный функтор — те же яйца, только к нему добавили еще одну фичу чтобы конвертацию можно быть делать отложенно в некоторых условиях, когда хотят скомпоновать содержимое нескольких контейнеров, опять же ничего не извлекая. Например
(Option[username], Option[password]) -> Option[(username, password)])
. С функциейmap()
мы бы не смогли сделать такую пару, не извлекая самих значений (нам сначала бы пришлось получить логин и пароль, а потом сложить в новый контейнер их пару). Поэтому тут добавляется еще одна функцияap()
(от apply), которая «лениво» преобразуем данные (как делал ее брат — Функтор) только когда кто-то начнет прочитать результирующий контейнер. На практике она возвращает частично примененную функцию — это ту, которая…Частично примененные функции, Каррирование и Кайнды. Объяснение на простом примере функции с двумя переменными: давайте подставим в нее первый параметр, а второй оставим пока неизвестным. По факту получим функцию с одним параметром. Вот и все, мы только что сделали «каррирование» функции из кайнда
k=2
вk=1
. Кайнд (kind) — это тип функции. Условно говоря функция с одним параметром — это один кайнд, а функция с двумя параметрами — другой кайнд (ну очень примитивно объяснил, понимаю). Дело в том, что в Хаскеле, например, вообще нет понятия количества параметров у функции. Например, если функции измерения расстояния надо 3 координаты (имеет сигнатуруDouble -> * Double -> * Double -> * Double
), то мы можем в выражениях использовать ее как с одним, так и с двумя или с тремя параметрами. Отличия будут в типах возвращаемых результатов. В случае если мы передадим все координаты, то она вернет «Double», если передадим на одну координату меньше — она вернетDouble -> * Double
, т.е. функцию от одного параметраDouble
, если мы передадим всего одну координату, то результат будет иметь видDouble -> * Double -> * Double
(функция от двух параметровDouble
, возвращающаяDouble
).Лямбда выражения и Замыкания. Лямбда исчисление имеет к ФП такое отношение, как Теория Категорий, т.е. никакое. Просто люди, привнесшие эту концепцию в ФП, были прожжёнными математиками, и дали ей такое название. Для того чтобы понять суть «лямбд» и «замыканий» не нужна высшая математика. Лямбда-выражение — это просто анонимная функция. Когда у тебя есть язык, весь состоящий из функций, и когда функции можно передавать в качестве значений другим функциям, то не очень хочется для каждой такой функции придумывать имя. Особенно если эта функция состоит из одной строки и тройки-другой слов.
Эффект — это один из столпов ФП, наравне с монадой (и настолько же абстрактен, как она). Эффект — это императивная часть программы. Любой программе, написанной на чистом и няшном ФП, приходится взаимодействовать с внешним миром. Любое взаимодействие заставляется выйти из теплого мирка контейнеров-монад в грязный реальный императивный мир и что-то вывести на экран, что-то принять по сети, прочитать текущее время и т.п. Кроме того любое извлечение данных из контейнера — это Эффект (т.к. с извлечением может быть запущена отложенная реальная обработка данных). Чтобы вывести распарсенное число на экран, нам придется-таки узнать, а было ли оно вообще распарсено (извлечь содержимое
Option
/Maybe
). Не удивительно, что функциональщики стараются держать Эффекты под контролем. Весь прикол функционального мира состоит в том, что Эффекты тоже до самого последнего момента тоже остаются монадными (т.е. упакованными с свой контейнер эффектов). Если где-то в коде ФЯП написано, что надо что-то вывести в консоль, то оно (текст) будет упаковано в монаду и доставлено вверх по кол-стеку прямо в функцию main. Функцияmain
возвращает именно такую супер-монадуIO
(а неvoid
как в «сях»), которая собрала в себя всю логику программы, и все эффекты ввода-вывода в консоль. Только внутренний boot-код, сгенерированный компилятором, запустит исполнение Эффекта (извлечение контейнераIO
) — откроет ящик Пандоры, из которого выскочат все реальные строки, вычисленные тут же «на лету» цепочками различных преобразований.- Эффект — это, на самом деле, венец всего ФП, после понимания которого наступает долгожданный катарсис «я наконец-то понял!».
Что дальше
Я надеюсь, что мое объяснение было полезным и дало вам привязку мира ФП к реальным задачам. Поэтому если вы еще не начали, то попробуйте начать писать функциональный код. Вот прямо сразу, на том языке, на котором вы пишете все время. Как я упоминал выше, это можно делать почти в любом языке ФП. Для этого надо всего лишь стараться максимально следовать следующим принципам:
- Писать чистые функции — функции, которые оперируют только теми данными, которые получили на входе, никак их не меняя.
- Не использовать глобальные переменные и другие хранилища состояния в процессе обработки — выполнять Эффекты только в самом конце работы логики.
- Аккуратнее с ООП. Изменяемые Объекты — это глобальные переменные. Старайтесь по возможности использовать immutable структуры данных.
- Если ваш ЯП уже содержит функции
map()
и различные вариации монад (Option
,Try
и т.п.) старайтесь использовать их по максимуму. - В следующий раз попробуйте вместо цикла for написать
map
/forEach
/fold
/reduce
или использовать другой Функтор, подходящей сигнатуры. Нет подходящего? Напиши его!
Заключение
Аппетит приходит во время еды. Постепенно развивая в себе функциональное чутье. Со временем вы постепенно начнете «видеть» монады. Ваш код станет выразительнее, компактнее, надежнее. Но есть один недостаток: взглянув через год на свой код вам станет нестерпимо стыдно и захочется переписать его заново вдвое короче. По крайней мере так было у меня.