8 June

Почему функциональное программирование такое сложное

Perfect codeScala
Tutorial

Я несколько раз начинал читать статьи из серии «Введение в функциональное программирование», «Введение в Теорию Категорий» и даже «Введение в Лямбда Исчисление». Причем и на русском, и на английском. Каждый раз впечатление было очень сходным: во-первых, много новых непонятных слов; во-вторых, много новых определений, которые возникают из ниоткуда; в-третьих, совершенно непонятно, как это использовать.


Самым непонятным и зубодробительным оказалось, наверное, Теория Категорий. Я освоился в ней только с третьего подхода. В первые два раза я честно все прочитал, кажется понял, но т.к. никакой связки с реальной жизнью она не имела, то спустя неделю она благополучно полностью выветривалась.


Попытки использовать как-то в работе изученные концепции разбивались о полное непонимание, как применить полученное глубокое знание. Ведь, напомню, что парадигму ФП (где-то удобнее, где-то не очень, но) можно использовать практически в любом ЯП, совсем необязательно для этого изучать условный Хаскель.


Кому эта статья


Эта статья для программистов, давно желавших понять Функциональное Программирование, пытавшихся что-то почитать на эту тему и упершихся в стену «да что, это блин за хрень такая, и зачем все так усложнять!?». Поэтому в этой статье я попытаюсь ответить на вопрос «зачем они это придумали», не сильно ударяясь в технические дебри. Я сегодня побуду таким «Робертом Киосаки от функционального программирования», который не столько учит вас финансовой функциональной грамотности, сколько мотивирует ее в себе развивать.


Дисклеймер


Я не претендую на звание эксперта в функциональном программировании или Теории Категорий. Далее в статье я излагаю довольно упрощенный и частный взгляд на довольно нетривиальные вещи. Прошу отнестись с пониманием к неточностям и ошибкам, ведь даже «истина, высказанная словами – есть ложь». Тем не менее я буду рад уточнениям и исправлениям, отправленным в личку.


Зачем нам Функциональное Программирование?


Изучение ФП делает разработчика профессиональнее. Я даже не буду приводить ссылки на пруфы, потому что в 2020 это уже просто незыблемая истина.


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


Тем не менее уровень входа в ФЯП крайне высок. Иногда настолько высок, что вполне состоявшиеся разработчики с многолетним и успешным опытом не могут освоить его принципы, даже честно пытаясь. Иногда непонимание концепций ФП приводит к анекдотичному причислению некоторых ЯП к числу функциональных лишь на том основании, что в них есть функция map(). Разработчики могут искренне заблуждаться, считая, что они уже освоили ФП.


Что я понял по результатам моих попыток.


Есть общая беда всех курсов из разряда «Введение в …» (даже со смешными картинками) – они дают пачку базовых определений, постепенно повышая сложность. «Почему это беда?», спросите вы. Отвечу аналогией: когда вы учите двухлетнего ребенка отличать круглое от квадратного, вы не даете ему геометрической аксиоматики, «что такое прямая», «что такое окружность» и т.п. плавно подводя к определению «шар» и «куб». Вы просто даете ему кубики и шарики и доску с дырками, в которую их надо вставить. Только значительно позже, уже в средних классах, он узнает их формальные определения. Конечно, программист с опытом – не двухлетний ребенок. Но встроенная в каждого человека нейросеть эволюционировала как инструмент обобщения разрозненных примеров в общий абстрактный принцип, а совсем не как инструмент, получающий готовые абстрактные принципы. Обучение на примерах всегда идет быстрее нежели сухое перечисление определений.


Анекдот:


– Пап, а как пишется восьмерка?

– Как "бесконечность", повернутая на пи-пополам».

Аналогично, при чтении очередного определения «монады» через функтор, у меня в голове возникала только одна мысль: вроде все понятно, но непонятно НАХРЕНА. Ощущения примерно соответствуют этой картинке:



Какую задачу они пытались решить, если в условной Java и так все работает без этих ваших всяких монад. Между тем, как будет показано ниже, за каждой очередной функциональной абракадаброй стоит вполне реальная решенная задача, понятная любому программисту с 2-3 летним стажем.


Сначала немного бла-бла


Так уж исторически сложилось, что основная терминология ФП пришла из мира математики. Причем ОЧЕНЬ абстрактной математики. Теория Категорий, разработанная в 1940-х годах – это настолько абстрактная теория, что она полностью оторвана не только от реального мира, но и от многих разделов «обычной» математики. По своей абстрактности она близка к формальной логике «на стероидах».


Хорошая новость состоит в том, что для того, чтобы понять ФП совсем не обязательно начинать (или вообще знать) Теорию Категорий. Я советую сначала разобраться в практике ФП, а потом уже копать в сторону «корней» теоретической основы.


Плохая новость состоит в том, что с этой оторванной от реальности терминологией придется смириться и привыкнуть. Поэтому я буду все время смешивать абстрактные термины с общечеловеческими, чтобы вы к ним привыкали.


Абстрактность


Любую архитектурную проблему можно решить введением дополнительного слоя абстракции, кроме проблемы излишнего количества слоев абстракции.

(с) бессмертное


Следуя подходу Теории Категорий «обобщай пока есть что обобщать», в ФП обобщают все что можно. Если что-то можно обобщить или абстрагировать – оно будет обобщено и абстрагировано. В итоге все приходит к условной абсолютно абстрактной «монаде», которая как «Многоликий Будда» не может быть описана одним предложением (хотя ниже я попытаюсь).



Следование этому пути приводит к полному исключению не только дублирования кода, но и дублирования алгоритмов и даже примитивов языка.


  • Если ФП-программист видит две функции с циклами внутри, то он пишет одну библиотечную функцию, которая реализует абстракцию «цикл» с параметром «тело цикла». Заодно делая ее таким образом, чтобы вложенные циклы выглядели как параметр для параметра «тело». И т.п.
  • Если ФП-программист видит два оператора if, то он пишет функцию, которая принимает предикат и возращает монаду, превращая весь код в цепочку вызовов функций map.

Если у тебя из инструментов есть только функции, то рано или поздно все начинает казаться монадой.

(с) мое.


Декларативность


Сложности с пониманием ФП у «обычного разработчика» во многом обусловлены также необходимостью смены парадигмы императивного программирования на декларативное.


Дело в том, что исторически прямо со времен зарождения программирования все обучение новых программистов ведется на алгоритмах последовательного выполнения инструкций для получения некоего результата – императивном программировании. Императивно устроены большинство и старых, и даже новых ЯП. В противоположность Императивному было разработано Декларативное Программирование, к которому относится в том числе ФП. Не вводя абстрактных определений, просто приведу сравнительную таблицу двух подходов на житейских примерах:


Решаем задачу Императивно Декларативно
Найти клад Если на улице дождь, надень куртку, выйди из дома, пройти 100 шагов на север, 200 на восток, возьми лопату, копай пока не наткнешься на клад Достань подходящим инструментом из земли клад прибыв на координаты (Lat, Lon) в подходящей одежде.
Сварить борщ Помой морковь, почисти лук. Обжарь их на сковороде в подсолнечном масле. Свари бульон на мясе, вынь мясо, положи туда обжаренные лук и морковь. Порежь картофель и свеклу, брось в бульон. Вари 20 минут. Вари до готовности содержимое кастрюли, наполненной бульоном из-под сварившегося мяса, в которую положил порезанные и чищенные картофель со свеклой и обжаренные в подсолнечном масле порезанные чистый лук и морковь.

Видно, что наши «программы» отличаются «точкой зрения на проблему». Императивный подход – постепенно конструируем результат от простого к сложному. Первая написанная функция императивного программиста будет «Чистить(Овощ) = …». Декларативный – наоборот: начинаем с самого конца, постепенно декомпозируя задачу на более мелкие. Первая написанная функция будет называться «ГотовыйБорщ = …».


Но есть еще более существенная разница. Дело в том, что императивная версия программы по поиску клада выполнится корректно только для конкретной начальной точки (хоть метр в сторону – и все было напрасно). А если в процессе варки борща, окажется что нет свеклы, то мало того, что борщ не сварится, так еще и зря пропадут уже порезанные продукты. А при попытке перезапуска процесса варки морковь окажется порезанной дважды. Поэтому основная проблема императивного подхода – большая чувствительность к начальному состоянию и прочим глобальным переменным в процессе исполнения. Что приходится компенсировать бесконечными if-ами, assert-ами, и обмазывать толстым слоем контрактов и тестов.



Может показаться, что я "натягиваю сову на глобус", выставляя декларативное программирование святым граалем. Но истина в том, что за все приходится платить. Основной недостаток Декларативного подхода – это необходимость прописывания всех (вот прям вообще всех, Наташ!) ограничений на все данные на всех этапах обработки – еще до запуска, что повышает сложность на этапе программирования, но отплачивает сполна в процессе работы. Этот полу-магический эффект, «если скомпилировалось, значит работает», замечают практически все, кто изучает ФП. Плюс функциональный код легко распараллеливается.


Обработка всех случаев и протаскивание контекста на все уровни создавало бы дикое количество бойлер-плейта в реальных программах. ФП научилось бороться с этим явлением, используя монады, о которых позже.


Чистая функциональная программа – это не поток исполнения (control flow), а поток данных (data flow), сопровождаемый монадическими Эффектами (о них чуть позже). Программа на чистом ФЯП – это такая многослойная матрешка, которая не делает ничего, пока не начнешь ее раскрывать слой за слоем. В процессе чего из нее иногда будут «вываливаться» Эффекты, сигнализирующие, что что-то там фактически программой было сделано.


Кроме того, декларативный подход «сверху-вниз» позволяет создавать библиотеки невероятной мощности: библиотека выкапывания всего и везде, библиотека варки любых блюд и т.п., которые дают сразу наборы функций из «верхней части» алгоритма (типа «варить до готовности»). Остаётся только дописать «нижнюю» половину – в каком порядке складывать в кастрюлю. Библиотека, работающая с монадами, работает с ЛЮБЫМИ монадами (которые удовлетворяют специальным «законам», общим для всех монад). Это обобщенное программирование, возведенное в Абсолют.


Отмечу также, что приведенные выше примеры – это «сферические кони». На практике даже в императивных ЯП программа пишется, начиная с функции main. И часто дальнейшее проектирование тоже происходит «по-декомпозиционному», т.е. сверху вниз. А опытный программист может написать все в декларативном стиле даже на «голых сях». С другой стороны, и в ФЯП программа, будучи декларативной по своей природе будет все равно чаще всего написана, как цепочка последовательных преобразований данных от начального состояния к конечному. Поэтому все же стоит вспомнить избитый принцип «императивный код определяет, что надо сделать, а декларативный код – что надо получить».


Декларативный язык всегда требует исполнительного механизма (иногда довольно сложного), способного из описания «что надо» понять, "как" это получить. А декларативная программа должна содержать достаточно подробное описание, чтобы исполнительный механизм мог понять, как это сделать. Из этого следует второй большой недостаток ФП: не всегда удается получить производительный код, напрямую используя выразительные декларативные конструкции. В таких случаях приходится вспоминать, что там под капотом, и давать компилятору подсказки по оптимизации либо просто отказываться от красивостей в пользу менее красивого, но более производительного кода.


Примерами декларативных систем и их исполнительных механизмов являются:


Декларативный язык Императивный исполнитель
Почтовая адресация Почтальон, навигатор
HTML+CSS Движок рендера в браузере
ФЯП Компилятор языка, генерирующий императивный код для CPU
Excel Spreadsheet Движок вычислений Excel
SQL СУБД
ReactJS.Component ReactJS Library

Прошу к столу


Теперь наконец попробуем разобраться в терминах Теории Категорий на понятных условному сишнику/джависту примерах.


  • Категория – любой примитивный или составной тип данных: строка, число, пара строка-число (кортеж), массив чисел, тип функций (например, функция IntToStr имеет тип Integer -> String). Функциональные типы (т.е. сигнатуры) – полноценные типы. Можно из них тоже собрать кортеж или сложить в массив. Параметры обобщенных типов (те, которые с дженериками, т.е. в Array[Int], например, Array – это обобщённый тип, а Int – это его параметр) еще могут быть Ковариантными/Контравариантными/Инвариантными. Эта тема стоит отдельной статьи.


    • Важное уточнение: данное тут определение очень вольное. Категория — понятие еще более абстрактное, чем тип. Но для начала понимания, давайте примем пока это упрощение.

  • Морфизм – это любая функция, преобразующая один тип в другой. Вот IntToStr – это вполне себе морфизм. Итого: видим «морфизм» — читаем «функция конвертации». «Эндоморфизм» — это морфизм внутри категории, т.е. преобразование типа в самого себя. Функция «синус» вполне себе Эндоморфизм из Категории Double в нее же, хотя и крайне примитивный. Более сложный пример морфизма: преобразователь пары строк (username, password) в объект сессии.


  • «Ее Величество» Монада – это простой и банальный контейнер. Ее основная цель – обрабатывать данные в контейнере, не вынимая их наружу. Для этого к ней прицепили парочку функций (map). Например, если у нас есть монада-список (массив) чисел, то преобразование их в строки можно сделать прямо в массиве, сразу получив на выходе готовый массив строк, не заморачиваясь с циклами, созданием новых массивов и т.п.


    • Важное уточнение: когда я говорю «превратили числа в строки, не доставая из контейнера», я не имею в виду, что поменялось содержимое самого массива. Исходный массив (экземпляр) остается неизменным, но вызвав преобразование, мы получим второй массив (или дерево, или любой другой контейнер) идентичной структуры, только уже содержащий строки.
    • Но это только половина правды. Вторая половина состоит в том, что когда вы получили указатель на массив строк, никакого массива еще нет. Все вычисления "ленивые". Это означает, что пока вы не попытаетесь прочитать что-то из этого "массива" (который на самом деле просто аналог сишного Handle) ничего выполнено и сконвертировано не будет. Поэтому вы можете строить цепочки конверсий, которые мгновенно возвращают управление (потому что ничего не делают), и в конце, когда вам понадобится что-то достать из конечного контейнера, только тогда вся цепочка и раскрутится в последовательность вызовов конкретных IntToStr и им подобным.
    • В хаскеле функция такой обработки называется bind (>>=), что имеет корни в Теории Категорий. Ведь bind – это «связывание», т.е. функция bind фактически создает ребро в графе категорий (связывает узлы). В большинстве языков «здорового человека» эта функция называется map() (строго говоря, flatMap) («отобразить», «поставить в соответствие»). По мне, логичнее было бы ее назвать 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. На самом деле в Хаскеле, например, вообще нет понятия количества параметров у функции в том смысле, как это делается в си-подобных языках. Например, если функции измерения расстояния надо 3 координаты (имеет сигнатуру Double -> Double -> Double -> Double), то мы можем в выражениях использовать ее как с одним, так и с двумя или с тремя параметрами. Отличия будут в типах возвращаемых результатов. В случае, если мы передадим все координаты, то она вернет «Double», если передадим на одну координату меньше – она вернет Double -> Double, т.е. функцию от одного параметра Double, если мы передадим всего одну координату, то результат будет иметь вид Double -> Double -> Double (функция от двух параметров Double, возвращающая Double).


    • А если мы такую же логику применим к обобщенным типам (дженерикам), т.е. рассмотим некий тип F[T1, T2, T3], то окажется что у такого типа есть конструктор, дающий конкретные реализации обобщенного типа (например F[Int, Double, String]). У этого конструктора будет 3 аргумента: T1, T2, T3. Действовать с ними он будет ровно так же, как вышеописанная функция. Т.е. его тоже можно "каррировать", уменьшая количество параметров, передавая часть из них. Только вот в этом случае не говорят о арности, а говорят о разных "кайндах" (kind). Почему? Потому что гладиолус.

  • Лямбда выражения и Замыкания. Лямбда исчисление имеет к ФП такое отношение, как Теория Категорий, т.е. никакое. Просто люди, привнесшие эту концепцию в ФП, были прожжёнными математиками, и дали ей такое название. Для того чтобы понять суть «лямбд» и «замыканий» не нужна высшая математика. Лямбда-выражение – это просто анонимная функция. Когда у тебя есть язык, весь состоящий из функций, и когда функции можно передавать в качестве значений другим функциям, то не очень хочется для каждой такой функции придумывать имя. Особенно если эта функция состоит из одной строки и тройки-другой слов.


  • Эффект – это один из столпов ФП, наравне с монадой (и настолько же абстрактен, как она). Эффект – это императивная часть программы. Любой программе, написанной на чистом и няшном ФП, приходится взаимодействовать с внешним миром. Любое взаимодействие заставляется выйти из теплого мирка контейнеров-монад в грязный реальный императивный мир и что-то вывести на экран, что-то принять по сети, прочитать текущее время и т.п. Кроме того любое извлечение данных из контейнера – это Эффект (т.к. с извлечением может быть запущена отложенная реальная обработка данных). Чтобы вывести распарсенное число на экран, нам придется-таки узнать, а было ли оно вообще распарсено (извлечь содержимое Option/Maybe). Не удивительно, что функциональщики стараются держать Эффекты под контролем. Весь прикол функционального мира состоит в том, что Эффекты до самого последнего момента тоже остаются монадными (т.е. упакованными в свой контейнер эффектов). Если где-то в коде ФЯП написано, что надо что-то вывести в консоль, то оно (текст) будет упаковано в монаду и доставлено вверх по кол-стеку прямо в функцию main. Функция main возвращает именно такую супер-монаду IO (а не void как в «сях»), которая собрала в себя всю логику программы, и все эффекты ввода-вывода в консоль. Только внутренний boot-код, сгенерированный компилятором, запустит исполнение Эффекта (извлечение контейнера IO) – откроет ящик Пандоры, из которого выскочат все реальные строки, вычисленные тут же «на лету» цепочками различных преобразований.


    • Эффект – это, на самом деле, венец всего ФП, после понимания которого наступает долгожданный катарсис «я наконец-то понял!».


Что дальше


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


  • Писать чистые функции – функции, которые оперируют только теми данными, которые получили на входе, никак их не меняя и возвращая обработанный результат.
  • Не использовать глобальные переменные и другие хранилища состояния в процессе обработки – выполнять Эффекты только в самом конце работы логики.
  • Аккуратнее с ООП. Изменяемые Объекты – это глобальные переменные. Старайтесь по возможности использовать immutable структуры данных.
  • Если ваш ЯП уже содержит функции map() и различные вариации монад (Option, Try и т.п.) старайтесь использовать их по максимуму.
  • В следующий раз попробуйте вместо цикла for написать map/forEach/fold/reduce или использовать другой Функтор, подходящей сигнатуры. Нет подходящего? Напиши его!

Заключение


Аппетит приходит во время еды. Постепенно развивая в себе функциональное чутье, со временем вы постепенно начнете «видеть» монады. Ваш код станет выразительнее, компактнее, надежнее. Но есть один недостаток: взглянув через год на свой код вам станет нестерпимо стыдно и захочется переписать его заново вдвое короче. По крайней мере так было у меня.


Апдейт по мотивам комментариев


Еще раз напомню, что данная статья не является академической. Данные тут определения — это вольные трактовки на конкретных житейских примерах, они способны вызывать батхёрт у тех, кто уже углубился в абстракции и топит за "чистоту". За строгостью формулировок я настоятельно рекомендую обратиться к авторитетным источникам.

Tags:туториалвведениеscalahaskellфункциональное программированиеочередная статья с банальностямибонус для внимательных
Hubs: Perfect code Scala
+138
72.1k 480
Comments 714
Top of the last 24 hours