Многие мои собеседники стопроцентно уверяли меня, что сама суть функционального программирования заключается в повсеместном использовании 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%) содержат это. Но тут есть два «но»:
<$>
используется во многих Applicative, и даже поверхностный просмотр кода покажет, что большинство применений никак не связаны со списками.Даже когда это списки, у
<$>
есть собственный смысл: «применить функцию к структуре данных через аппликатив». Опытный хаскелист мыслит им не как «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
в императивных языках почти не улучшают композицию кода, а часто даже мешают.
А вот если у вас куча «очищаемых» компонентов - это уже кладезь: их легко комбинировать, выделять в микросервисы, развивать архитектуру. И это, в масштабе проектов, несравненно важнее, чем то, как именно мы пишем циклы.