Многие мои собеседники стопроцентно уверяли меня, что сама суть функционального программирования заключается в повсеместном использовании map, filter и reduce; что эти функции превосходят циклы for во всём, настолько, что их нужно запихнуть в каждый возможный язык безо всякого анализа затрат и выгод, потому что выгоды настолько несравненно потрясающие, что затраты просто не могут иметь значения. А само сомнение в этих затратах уже доказывает, что я ничего не понял. Поэтому пора задать главный вопрос: действительно ли именно они (map, filter и reduce) и есть ядро функционального программирования?

К чёрту теорию; давайте посмотрим на практику. Давайте взглянем на реальный проект на Haskell. Я знаю два крупных проекта на Haskell, которые вышли за пределы хаскель-экосистемы и стали полезными программами, которые люди скачивают: xmonad и pandoc. Но xmonad - странная программа: она делает массу привязок к библиотекам C и взаимодействует со всевозможными системными сервисами…Что, конечно, неплохо, как говорится, но из-за этого она не очень типична. А вот pandoc - это чистейший Haskell: парсинг, работа с абстрактным синтаксическим деревом, его трансформация и генерация; т.е., по сути, огромный компилятор для документов. Более «хаскельским» код быть не может. Давайте посмотрим на него.

Исходники Pandoc

Я беру последнюю релизную версию на момент написания - 3.6.2.

Для начала - общий план. Исходники лежат удобно в папке src, отделённые от тестов, так что я делаю cd src. Запускаю cloc внутри:

$ cloc .
     221 text files.
     221 unique files.                                          
       0 files ignored.

github.com/AlDanial/cloc v 1.98  T=0.20 s (1107.4 files/s, 448342.4 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Haskell                        221           7655           8157          73661
-------------------------------------------------------------------------------
SUM:                           221           7655           8157          73661
-------------------------------------------------------------------------------

Итак, мы имеем дело с 73661 строкой Haskell-кода.

map/filter/reduce

Очевидно, код должен быть забит вызовами map/filter/reduce, верно? Давайте посмотрим.

Замечу: все grepы и подсчёты я проверял вручную, чтобы быть хотя бы примерно точным. Да, где-то может быть ошибка, но изменение «47 на 49» не изменит выводов.

Я исключил import, потому что это не настоящие вызовы. В Haskell есть разные виды fold для того, что в других языках зовут reduce, так что:

$ egrep -r fold[lr]\'?\b * | grep -v import | wc -l
154

Чуть лучше, но всё ещё лишь 0.27% строк.

А как насчёт главного героя? Того самого признака «чистого кода», который навсегда отменил циклы for?

$ egrep -r '\bmap\b' * | wc -l
847

Это 1.1%.

Для сравнения:

$ egrep -r '\bmapM\b' * | wc -l
581

То есть 0.8%, почти столько же.

«Ага, попался! mapM - это же тот же map

Нет, вовсе нет. Код, который люди называют «функциональным стилем», использует именно map. Бесконечные вызовы map, для итерации по массивам. Если бы там были более глубокие абстракции, я бы не спорил, но это именно map, map и map по массивам или их аналогам.

На деле это даже не функциональное программирование, а какая-то вырожденная форма программирования на массивах, да ещё и с урезанным понятием «массив». Да, это интересная парадигма сама по себе, но полноценные array-языки используют больше, чем просто map. Запихивать такое урезанное array-программирование в чужие языки - ещё хуже, чем пытаться натянуть на них настоящие fp-практики.

mapM - это вообще другая рекурсивная схема (о ней позже). Но сначала нужно закрыть вопрос, который волнует всех знающих Haskell: а что насчёт <$>?

Ответ: 2311 строк (3.1%) содержат это. Но тут есть два «но»:

  1. <$> используется во многих Applicative, и даже поверхностный просмотр кода покажет, что большинство применений никак не связаны со списками.

  2. Даже когда это списки, у <$> есть собственный смысл: «применить функцию к структуре данных через аппликатив». Опытный хаскелист мыслит им не как «map по списку», а как отдельный инструмент.

Тем не менее, это явно заметная доля.

Но главный вопрос в другом: когда люди утверждают, что пишут «функционально» в JavaScript, как это выглядит? Судя по моим наблюдениям - их код не на 1% состоит из map, а процентов на 25.

Другие абстракции

Да, в pandoc есть немало map/filter/reduce. Но не забывайте: я управляю лучом вашего внимания. Давайте расширим кругозор и посмотрим на другие схемы рекурсии. Pandoc активно использует аппликативные функторы. Проверим <$ и другие аппликативные операторы:

$ egrep -r '<\$[^>]|[^<]\$>|<\*>|<\|>|liftA|[^>]\*>|<\*[^>]' * | wc -l
2201

Это около 3% строк.

А что насчёт монадо-абстракций?

$ egrep -r '<-|>>=|mapM|return|mzero|mplus|mapM|forM|sequence|>=>|<=<|msum|mfilter|filterM|foldM|replicateM' * | grep -v -- -- | wc -l
10579

То есть монады используются в 14.4% строк, часто по несколько раз на строку. И это без учёта библиотеки mtl.

«Ну да, монады…»

Стоп-стоп, ковбой. Я сказал «монады», а не Either (он же Option в других языках). Pandoc действительно использует монады, а не только Either.

Для наглядности - посмотрим на сигнатуры функций в Pandoc:

$ grep -r :: * | wc -l
6477

Теперь - сколько из них реально возвращают Maybe или Either?

$ grep -r :: * | grep '(Maybe|Either)(?!.*?->)' | wc -l
490

Не так уж и много. А упоминаний вообще?

$ grep -r :: * | grep '(Maybe|Either)' | wc -l
540

Тоже немного.

А теперь сравним с:

$ grep -r :: * | grep PandocMonad | wc -l
2491

Ого. А вот и PandocMonad. Это typeclass, который содержит все функции, потенциально связанные с IO, используемые ридерами и райтерами. Он может быть реализован в IO (PandocIO) или в «чистом» режиме (PandocPure).

И это куда важнее, чем Maybe.

Кроме того, используются:

  • ParsecT (и вообще монадо-трансформеры),

  • MonadIO для абстракции над IO,

  • разные утилиты для PandocMonad,

  • прочие монады вроде Reader, Writer и т.д.

Короче: монады - это не только Option. Если вы говорите «монада», имея в виду Option, вы используете термин неправильно.

Зачем всё это?

Чтобы показать на конкретном примере - функциональное программирование не сводится к map/filter/reduce.

Если кто-то утверждает обратное - значит, он просто не понял, что это такое.

И это нормально: никто не обязан проходить курс по ФП. Но, пожалуйста, хватит требовать, чтобы все «перешли на map/filter/reduce, потому что функциональное программировние».

Функциональное программирование - это про маленькие кирпичики и схемы рекурсии, из которых мы собираем всё более крупные конструкции, сохраняя их свойства. map/filter/reduce - это кирпичи. Но архитектуру кирпичик за кирпичиком вы не построите. Нужно подниматься выше - к своим абстракциям, как PandocMonad.

Более того, на практике map в императивных языках часто вредит. Одиночный вызов - нейтрален. Но цепочки map быстро превращаются в нечитаемый код, который хуже для понимания, чем обычные циклы. Это серьёзный минус.

Императивный код не может делать то же, что функциональный, но может перенимать саму идею - строить уровни абстракций из простых примитивов. И в этом разговоре map/filter/reduce - это нижний уровень, а настоящие преимущества начинаются на средних и верхних уровнях.

Программа на императивном языке будет гораздо более «функциональной», если она использует обычные циклы, но её компоненты легко «очищаются» и комбинируются, чем если она напичкана map/filter/reduce, но при этом вся архитектура построена на мутациях и побочных эффектах. Потому что сами по себе map/filter/reduce в императивных языках почти не улучшают композицию кода, а часто даже мешают.

А вот если у вас куча «очищаемых» компонентов - это уже кладезь: их легко комбинировать, выделять в микросервисы, развивать архитектуру. И это, в масштабе проектов, несравненно важнее, чем то, как именно мы пишем циклы.

Комментарии (6)