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

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

Постепенно ответ всё-таки начал складываться, причём оказался он немного не таким, как я ожидал. Теория категорий не дала мне какой-то секретной техники написания программ, после которой код внезапно стал в десять раз лучше. Я не начал рисовать коммутативные диаграммы перед каждым пулл-реквестом и пока ещё не требую от коллег доказательства законов на код-ревью. Изменилось другое: я стал гораздо чаще замечать, что совершенно разные на первый взгляд задачи имеют одну и ту же форму. А если форма одна и та же, возможно, и решать их каждый раз с нуля не обязательно.

Хорошая абстракция

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

Допустим, где-то в приложении мы дважды пишем примерно такое:

const fullName = `${user.firstName} ${user.lastName}`

а потом то же самое появляется ещё в трёх местах. Через некоторое время становится понятно, что у нас есть повторяющаяся операция, и рождается:

function getFullName(user) {
  return `${user.firstName} ${user.lastName}`
}

Здесь довольно легко увидеть, откуда появилась абстракция. Код похож, данные похожи, задача похожа. Мы просто посмотрели на несколько конкретных случаев и вынесли общее.

Мне долго казалось, что примерно так абстракции и работают в принципе: сначала встречаешь несколько похожих вещей, потом находишь у них общую часть и даёшь этой общей части имя. Интерфейсы, базовые классы, дженерики, функции высшего порядка – технические способы могут отличаться, но мысль примерно одна. А потом я впервые наткнулся на довольно странный для себя способ искать сходство. Возьмём самый скучный map:

const names = users.map(user => user.name)

Есть массив пользователей, есть функция User -> String, после применения map получаем массив имён – ничего интересного, эта конструкция, используемая нами каждый день, не заставляет задуматься. А что, если начать выкидывать детали, оставив только форму?

есть F<A>
есть A -> B
получаем F<B>

Буква F здесь намеренно ничего не говорит о том, что именно у нас находится снаружи. Это может быть коллекция. Может быть значение, которое иногда отсутствует. Может быть успешный или ошибочный результат вычисления. В конкретном языке эти структуры будут называться по-разному, а некоторых в стандартной библиотеке может вообще не быть – например, в JavaScript нет встроенных Option и Result, хотя такие типы постоянно встречаются в других языках и библиотеках. Массив, опциональное значение и результат операции внешне вообще не похожи друг на друга, у них разные данные, разные причины существования и разное поведение. Если бы мне раньше предложили найти между ними хорошую абстракцию, я бы, скорее всего, даже не начал искать.

Но у них может совпадать не устройство, а отношение к преобразованиям. Мы можем взять обычную функцию A -> B и каким-то образом применить её к A, которое находится внутри структуры, получив ту же структуру уже с B:

F<A> -> F<B>

У массива это означает пройти по элементам. У условного Option<T> – ничего не делать, если значения нет. У условного Result<T, Error> – преобразовать успешное значение и оставить ошибку как есть. Собственно, эта структура и называется функтором, хотя сейчас мне гораздо интереснее не её название, а то, как мы вообще до неё докопались. Внутреннее поведение совершенно разное, но если смотреть достаточно издалека, у всех этих вещей обнаруживается одна и та же форма. И чем дальше я в это лез, тем чаще начал встречать тот же трюк в других местах.

Представим две задачи. В первой у нас есть несколько последовательных запросов. Сначала по userId нужно получить пользователя, затем из пользователя достать organizationId и загрузить организацию, а потом уже по данным организации получить платёжный аккаунт:

const user = await findUser(userId)
const organization = await findOrganization(user.organizationId)
const billingAccount = await findBillingAccount(organization.billingAccountId)

Во второй задаче мы, например, разбираем какой-нибудь входной формат. Сначала читаем заголовок, а в зависимости от значения в заголовке решаем, как интерпретировать следующую часть:

  1. прочитать тип сообщения

  2. в зависимости от типа выбрать парсер

  3. результат парсера определяет следующий шаг

Второй пример высосан из пальца, но суть сравнения понятна – предметно эти задачи находятся где-то на разных концах Вселенной. В одной база данных, пользователи и организации, во второй – байты и парсеры. Никакого общего интерфейса между User и куском бинарника я, пожалуй, вводить не стану. Но если снова начать выбрасывать детали, оказывается, что структура вычисления у них одинаковая: следующий шаг нельзя определить, пока мы не получили значение на предыдущем. У нас есть какое-то вычисление M<A> и функция, которая, увидев A, может решить, какое вычисление запустить дальше:

A -> M<B>

Нам нужен способ соединить их:

M<A> + (A -> M<B>) -> M<B>

Если ты уже состоишь в секте Свидетелей Эндофункторов хорошо знаком с ФП, то, скорее всего, узнал bind – одну из основных операций монады, позволяющую композиционировать зависимые вычисления. Если не знаком – сейчас это вообще не важно.

Сама по себе эта запись выглядит примерно так, как и должна выглядеть формула, которую человек придумал, чтобы нормальные разработчики боялись Haskell. Но если перед этим несколько раз столкнуться с одной и той же проблемой – цепочкой зависимых вычислений, каждое из которых несёт дополнительный контекст, – операция начинает выглядеть не искусственной математической конструкцией, а довольно естественным ответом на конкретный вопрос: как мне скомпозировать эти шаги так, чтобы каждая функция занималась своей работой, а правила окружающего контекста применялись автоматически?

Причем тут категории

Теория категорий позволяет создавать вышеописанные хорошие абстракции не потому, что в ней есть готовый справочник «какой паттерн применить в какой ситуации», а потому, что она предлагает довольно непривычный способ вообще смотреть на вещи. Её почти не интересует, что находится внутри объектов. Гораздо важнее, какие между ними существуют преобразования, как эти преобразования можно соединять и какие свойства сохраняются при таком соединении.

Если у нас есть преобразование из A в B и преобразование из B в C, то их можно скомпозировать и получить преобразование из A в C:

A ---f--> B ---g---> C
A ------g ∘ f------> C

Само по себе это, конечно, звучит как величайшее открытие человечества примерно уровня «если вызвать одну функцию после другой, получится вызов двух функций». Но дальше начинается тот же фокус, который мы уже проделали с массивом и монадой: можно перестать спрашивать, чем являются A, B и C. Более того, можно перестать считать стрелочки функциями. Оставить только какие-то объекты, какие-то переходы между ними и правила, по которым эти переходы композиционируются. Если добавить ещё несколько требований вроде существования тождественного преобразования A -> A и ассоциативности композиции – поздравляю, мы примерно и получили категорию.

Теория категорий предлагает выкинуть из задачи почти всё, что можно выкинуть, и посмотреть, какие свойства переживут эту процедуру. Если для рассуждения нам неважно, что такое A, как устроено B и почему существует C, значит, тащить эти знания дальше просто незачем. Остаются только объекты, связи между ними и законы, которым подчиняются эти связи. Причём даже на таком уровне абстракции можно строить довольно богатую математическую теорию, из которой в том числе растут все эти функторы, монады и прочие эльфийские изобретения.

Так зачем в итоге нужна теория категорий

Всё звучит красиво, но пока очень похоже на интеллектуальный онанизм. Ну увидел я, что два совершенно разных куска кода имеют одинаковую структуру, молодец. На зарплате как скажется?

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

Я всё ещё не могу сказать, что теория категорий нужна каждому программисту. Большинство разработчиков прекрасно проживут всю карьеру, не узнав определения категории, и это никак не помешает им писать хорошие системы. Если после этой статьи ты больше никогда не прочитаешь ничего про функторы, твой ноутбук не взорвётся, а GitHub не заблокирует аккаунт за недостаточную функциональность. Наверняка существует десяток областей, которые дадут отдачу быстрее: базы данных, сети, операционные системы, устройство конкретного рантайма, да что угодно. Я несколько месяцев читал про функторы не потому, что это был оптимальный способ поднять грейд, а потому что мне было интересно – иногда этого достаточно.

Но теория категорий научила меня чуть раньше замечать форму, которая скрывается за конкретной реализацией, и спрашивать не «на что похож этот код?», а «какие свойства здесь действительно важны и что я могу выкинуть, не потеряв их?». Если несколько сущностей выглядят похоже, это ещё не означает, что им нужен общий предок, интерфейс или очередной AbstractSomethingService. Гораздо интереснее понять, совпадают ли у них действительно важные для задачи свойства и одинаково ли с ними можно работать на нужном уровне. Иногда два совершенно разных типа оказываются одной и той же штукой с точки зрения конкретной операции, а иногда два практически одинаковых класса имеют настолько разное поведение, что объединять их общей абстракцией – значит заранее записаться на будущий рефакторинг с флагами legacyMode, skipValidation.

На этом, пожалуй, всё. Если тебе интересно наблюдать за тем, как человек добровольно закапывается в странности, у меня есть небольшой Telegram-канал this :: IO Diary. Там я пишу лонгриды о функциональном программировании, теории категорий и разработке в целом, делюсь своими экспериментами, открытиями и периодически рассказываю, до какой очередной странной идеи докопался. Собственно, эта статья во многом выросла из постов, которые я публиковал в канале, пока разбирался с функторами, монадами и прочими эльфийскими изобретениями. Буду рад, если заглянешь!