Недавно мне попался следующий твит от OneHappyFellow:
Кажется, я понял, что меня настолько напрягает при программировании на языках с динамической типизацией. Дело в том, что никогда нет уверенности, будет ли конкретная библиотека работать определённым образом, и не сломается ли код при очередном минорном обновлении версии.
— One Happy Fellow (@onehappyfellow) 5 мая 2025
Этот тезис меня заинтересовал. Дело в том, что по работе мне в основном приходится иметь дело с Clojure. Это динамический язык, но его экосистема на редкость известна своей стабильностью. В этой статье мы подробно разберём, почему именно так сложилось, но для начала я приведу некоторые доказательства, подкрепляющие мою точку зрения.
Стабильны ли библиотеки Clojure?
Я поискал, что выдаёт по запросу «stability» Slack-канал по Clojure. Оказалось, что из 20 постов, попавших на первую страницу поисковой выдачи, 8 таких, где пользователи с восхищением отзываются о стабильности, присущей Clojure. Именно этот slack-канал является важнейшей дискуссионной площадкой для специалистов по Clojure, здесь разбирают различные библиотеки, баги и багфиксы. Соответственно, логично было ожидать, что именно здесь будут особенно активно жаловаться по поводу стабильности языка. Естественно, мой поиск – это не случайная выборка, но он всё равно позволяет составить впечатление о том, насколько в сообществе Clojure ценят стабильность языка и гордятся ею.
В качестве ещё одного довода рассмотрим две следующие диаграммы из статьи «A History of Clojure». Здесь детально разобрано, в каком темпе вводился и закреплялся новый код, причём, сравниваются показатели для Clojure и Scala.


Притом, что эти показатели совсем не обязательно являются мерилом стабильности библиотек как таковых, логично предположить, что специалисты по поддержке Clojure делают упор на стабильности, и такое отношение должно пропитывать всё сообщество. Действительно, так и есть.
Рассмотрим, насколько успешно закрепляется код в различных популярных библиотеках. Я выбрал следующие библиотеки экспромтом, руководствуясь тремя критериями: все библиотеки должны иметь более 500 звёзд на Github и при этом активно использоваться. Естественно, я легко мог выбрать и больше.




Очевидно, авторы библиотек стараются развивать их в том же духе, в котором команда Clojure поддерживает язык.
В качестве последнего довода расскажу случай из жизни — он многое объясняет. Недавно я залил обновление для моей библиотеки Fusebox, обеспечивающей отказоустойчивость. Это обновление понадобилось из-за одного глюка в утилите повторных попыток. Всякий раз, когда выбрасывается исключение, оно обёртывается в метаданные, предоставляемые утилитой повторных попыток (например, сколько таких попыток было сделано). Затем оно выбрасывается повторно.
Один из пользователей Fusebox по имени Мартин Кавалар недавно пожаловался, что такое обёртывание происходит лишь в случае, если повторная попытка действительно произошла. Такой запрос не просто был правомерен — более того, пожалуй, с самого начала это поведение нужно было реализовать именно так, как в нём описано. Я даже готов признать эту недоработку багом.
Но это баг, к которому успели привыкнуть. Программисты пишут код, держа в уме, что с этим багом нужно справляться. Иными словами, если мы сделаем «как надо» и «пофиксим» баг, это нарушит работу ещё чьего-то кода.
Так я и сказал Мартину, и он согласился, что нужно найти такое решение, которое не ломает уже существующий пользовательский код. Не сказать, что в сообществе программистов так принято — наш брат печально известен своей страстью к долгим и затяжным дебатам по поводу мельчайших деталей. Но в сообществе Clojure считается комильфо быть корректным со всеми.
Поскольку практика показывает, что обычно библиотеки Clojure очень стабильны, задумаемся — а как это возможно? На первый взгляд, OneHappyFellow рассуждает логично: благодаря статической типизации сразу понятно, где происходит разрушительное изменение, поэтому и процесс обновления значительно упрощается. Эта загадка решается в два хода.
В чём специфика Clojure?
Если коротко, Clojure — так заведено — это самый статический из всех динамических языков.
OneHappyFellow в своём твите пару раз бьёт в точку: сериализация в динамических языках корявая, а при применении обезьяньего патчинга передача объектов из процесса в процесс превращается в кошмар.
Рассмотрим типичную программу на Javascript. Из чего она состоит? Из объектов, объектов и снова объектов. Никак не обойтись без интроспекции членов, либо придётся гадать о содержимом этих объектов. Я вам больше скажу — обезьяний патчинг при работе с этими объектами считается нормой, поэтому члены объектов могут со временем меняться (или не меняться).
Теперь рассмотрим типичную программу на Clojure. Из чего она состоит? Из пространств имён. В пространствах имён содержатся функции и данные. Бывает, что функции динамически генерируются (при помощи макрокоманд), но обезьяний патчинг при работе с пространством имён — исключительно редкая практика. Единственный раз я сталкивался с ней, когда пошёл на это сам и… не без причины. Если вы так поступаете, то вряд ли можете пенять на библиотеку в случае дальнейшего возникновения ошибок.
Если вам интересно, какие функции доступны в пространстве имён — просто почитайте файл с исходниками.
Более того, пространство имён никогда не сериализуется. Это просто бессмысленно. Нет, вы сериализуете данные, при этом все данные в Clojure поддаются сериализации прямо из коробки. Сериализуются они именно в том порядке, как записаны в исходном коде. Информация даётся в формате, называемом расширяемая нотация данных (EDN). В EDN даже можно создавать собственные теги, благодаря которым можно использовать специализированные конструкторы для данных (таких, как дата и время и чёрно-красные деревья).
У данных в Clojure есть ещё одно любопытное свойство: они неизменяемы. Поэтому они устойчивы к изменениям. Как только вам вручили хэш-таблицу, можете быть уверены — никто её не подправит без вашего ведома. Это невозможно. При вызове assoc и update будет возвращаться новая хеш-таблица, а не обновляться имеющаяся. Для этого предусмотрен эффективный способ, позволяющий совместно использовать одни и те же элементы сразу в старой и новой структуре. Таким образом, при передаче данных в другой процесс или по сети вы можете быть уверены, что получатель увидит то же самое, что видите и вы.
Наконец, хотел бы обратить ваше внимание на то, как в динамических языках именуются члены объектов. Обычно (а насколько я знаю — всегда) это делается просто и безыскусно. Например, .name
— это поле, а .doSomething()
— это функция. Естественно, это приводит к неоднозначности, когда вы хотите дать какому-либо объекту два имени. Возьмём, к примеру, объект user
. Сначала вы называете его user.name, а затем осознаёте, что нужно добавить и название организации. Здесь у вас уже несколько вариантов. Например, можно переименовать user.name
в user.username
, чтобы это соответствовало новому полю user.orgName. Но вот незадача — вы только что внесли разрушающее изменение.
Напротив, в Clojure поля обычно получают элемент пространства имён (эта концепция связана с пространствами имён, но отличается от тех пространств имён, в которых мы размещаем функции.) Поэтому, хотя user.name встречается как в Javascript, так и в Clojure, где оно примет вид {:user/name "OneHappyFellow"}
. Благодаря этому, если в коде найдутся «имена» других видов, то их можно будет бесшовно интегрировать в код, ничего не нарушая:
{:user/name "OneHappyFellow"
:organization/name "OCaml Bois"}
Наконец, приведу последнее замечание OneHappyFellow — гораздо более глубокое, подводящее нас к следующему вопросу.
Самое худшее, по-моему, наступает, когда я берусь рефакторить какой-то код — и в результате узнаю, что такой вариант рефакторинга фундаментально несовместим с какой-то библиотекой, которую я уже использую.
— One Happy Fellow (@onehappyfellow) 5 мая 2025
Почему из-за изменений в библиотеках ломаются программы?
Это непростой вопрос. Но для начала давайте перечислим причины, по которым вообще вносят изменения в библиотеки:
Исправления, связанные с повышением безопасности
Патчинг багов
Улучшения
Думаю, вы согласитесь, что (хотя бы в принципе) изменения из первой и второй категории должны быть неразрушающими. Исправления, призванные повысить безопасность, примерно никогда не должны ничего нарушать. При патчинге багов нарушения возможны, но они, как правило, минимальные, либо помечаются как wontfix.
Таким образом, остаётся один настоящий раздражитель — «улучшения».
«Улучшения» как таковые не обязательно означают «слом». В конце концов, если добавить к объекту новый метод, ничего не испортится. Так какие же «улучшения» действительно являются разрушающими? Приведу примеры:
Переименование метода (разрушающее)
Переименование типа (разрушающее)
Переименование поля (разрушающее)
Переименование пакета (разрушающее)
Изменение сигнатуры метода (как когда)
Вполне очевидно, что единственный пункт из этого списка, претендующий на обоснованность — последний. И здесь мы подходим к первой горькой истине, которую можно извлечь из этой статьи.
Все эти произвольные переименования убивают код.
Где же весь пафос, связанный со статическими типами? Все настойчивые утверждения, будто статические типы «устраняют» эту проблему? Напротив, только усугубляют. Статические типы здесь помогают ровно настолько, насколько веник помогает поддерживать чистоту на кухне, где привыкли разбивать об пол винные бокалы. Да, с типами немного легче, но они не решают проблему. Для начала нужно разобраться, почему у вас по всему дому разбросаны винные бокалы.
Почему вы то и дело всё переименовываете?
Как только заметишь этот тренд, развидеть его уже невозможно. Мы достаём записи из базы данных — и что делаем в первую очередь? Переименовываем поля. Затем мы проделываем несколько этапов преобразований, при которых также неизбежно будут переименования. Затем мы передаём эту информацию в формате JSON, и при этом опять не обойтись без переименований. Далее эта информация загружается в одностраничное приложение, но те имена, которые мы использовали при передаче, опять же, туда не попадают. Лучше переименовать ещё раз.
Это безумие, но мы сами создали такой мир.
Хотите — верьте, хотите — нет, но это и есть одна из основных причин, почему программы на Clojure так чертовски стабильны. Мы так не делаем. Не переименовываем сущности в наших библиотеках, а когда подтягиваем откуда-то данные — прилагаем все усилия, чтобы ничего не переименовывать.
Но переименование — не единственная позиция в нашем списке «разрушительных факторов». Что насчёт изменения сигнатур методов? Чтобы справиться с этой проблемой, давайте продолжим нашу категоризацию. Изменения какого рода могут спровоцировать видоизменение сигнатуры метода?
Разрешение — но не требование — записывать больше данных в параметрах (неразрушающее)
Требование указывать в параметрах больше данных (разрушающее)
Требование указывать в параметрах меньше данных (неразрушающее)
Возвращение большего количества данных в ответе (зависит от ситуации)
Возвращение меньшего количества данных в ответе (разрушающее)
Два элемента из этого списка вообще не должны причинять никаких проблем. Добавляя к функции опциональные входные параметры, мы, очевидно, ничего не нарушаем. Кстати, именно таким способом я в итоге устранил возникшую у Мартина проблему с Fusebox. Я добавил к сигнатуре функции опциональный ключ ::retry/exception
.
Если вам вдруг потребуется меньше входных значений — скажем, вы придумаете, как динамически решить некую задачу с сегментированием — это изменение может быть и неразрушающим. Однако, оно может оказаться и разрушающим (например, если раньше ваша функция принимала три аргумента, а теперь принимает всего два). Но, как минимум, в принципе, вы можете структурировать ваш код таким образом, чтобы пользователям не приходилось иметь дела с этим изменением.
В случае, когда мы начинаем возвращать больше данных, чем раньше, возникают дополнительные нюансы. Но, если функция возвращает дополнительное поле, после чего данные записываются непосредственно в базу, в которой не предусмотрен столбец для этого поля, то может быть выдана ошибка (в зависимости от конкретной базы данных). Вообще, если вы возьмёте за правило предварительно выбирать те данные, которые затем собираетесь передавать по сети, то избежите многих проблем такого характера, что описаны здесь.
Во-вторых, всегда хочется избежать ситуации, в которой требуется больше входных данных, а в ответ при этом возвращается меньше. Именно так ломают пользовательский код. Если будете избегать подобного, то никогда не сломаете повредите пользователю.
Возникает вопрос: а что, если найдётся гораздо более качественный способ решить задачу, но такой, при котором требуется больше данных на вход, а в ответ возвращается меньше? Ответ прост: напишите для этого новую функцию. Новые функции не ломают пользовательский код. Это просто новые возможности. Отличные! Вам даже не придётся редактировать тот старый код. Вы можете взять и переписать всё с нуля!
Опять же, типы в такой ситуации — чудесная выручалочка. «О, да зачем беспокоиться и требовать больше данных в этой функции. В случае чего включится проверка типов и предупредит пользователя». Это так, но не отменяет того факта, что вы вообще не должны допускать, чтобы у пользователей возникала такая потребность.
Почему библиотеки Clojure стабильны?
Если коротко, экосистема Clojure отличается такой аномальной стабильностью, так как мы стараемся ничего не ломать.
Не переименовываем пространства имён. Не переименовываем функции. Не переименовываем ключевые слова. Никогда не требуем сообщать нам больше входных данных, чем было ранее, а также не сокращаем объёма выдаваемых данных. Если мы находим какой-то более качественный способ что-либо сделать, то создаём новую функцию, новое пространство имён, можем даже написать целую новую библиотеку.
Следует отметить, что всё это достаётся не даром. Меняется ваш склад ума, поскольку вы привыкаете, что вот этот фрагмент кода с вами надолго. Вы становитесь разборчивее, более осознанно взвешиваете компромиссы. Вы учитесь подмечать закономерности, ограничивающие рост, и закономерности, благоприятствующие росту. Например, в истории сообщества Clojure был такой эпизод: мы отучились принимать в наших функциях списки аргументов и именованные параметры, а вместо них стали принимать единую хеш-таблицу. Дело в том, что обеспечить рост единственной хеш-таблицы проще.
В сообществе разработчиков эти принципы уже хорошо известны. Часто ли вам доводится переименовывать путь URL у вас в API? Никогда! Часто ли вы переименовываете ключ в вашем API? Никогда! Принимали ли вы когда-нибудь решение, что стоит возвращать меньше данных? Нет! Всякий раз, когда тянет сделать что-то подобное, что вы делаете? Задаёте новое имя (обычно v2).
Почему мы так поступаем? Потому что знаем, какие неудобства это доставит нашим пользователям, и хотим их от этого уберечь. Да, иногда при взаимодействии с другими разработчиками правила могут радикально меняться. Иногда оказывается, что причинять боль вполне допустимо.
Обратите внимание: эта проблема не сводится к простому противопоставлению статики и динамики. Правда, мне часто доводится видеть, как энтузиасты статической типизации громогласно заявляют о достоинствах проверщика типов, говоря: «Обновляя библиотеку, я знаю, что она будет работать». Довольно честно. Как минимум, вы знаете, что ваш код скомпилируется, и вам не придётся гадать, что и где изменилось. Но при этом умалчивается, сколько работы требуется выполнить, чтобы заставить обновление работать.
Вновь обращу ваше внимание на диаграмму закрепления кода в Scala. Сколько на ней таких пиков, которые скрывают массу работы, ложащейся на плечи пользователей? Много ли такой работы не пришлось бы делать, если бы просто был выбран подход «ничего не ломаем»?
Спасибо OneHappyFellow, Slim Jimmy и Matthew Boston за их вклад в эту статью.
Отдельное спасибо Евгению Пахомову не только за вклад в статью, но и за то, что предоставил столбчатые диаграммы по библиотекам Clojure.
Комментарии (12)
flancer
19.05.2025 21:32Каждый видит в статье то, что может увидеть - в меру своей "испорченности". Я, например, увидел пересечение с моими публикациями "Отвлеченно о входных/выходных аргументах" и "Так в чём же конечная цель программирования?".
Если что, то и на чистом JS можно написать функцию, устойчивую к изменению сигнатуры метода - с использованием декомпозиции входных-выходных аргументов:
function fn({a = 1, b = 'text', c = true}) { const res = {}; // do some work return res; }
В принципе, годная статья, если абстрагироваться непосредственно от Clojure, а держать в фокусе идею "неразрушающего рефакторинга". Позеленил.
janvarev
19.05.2025 21:32Аналогично. Вижу практики, которых сам придерживаюсь - "не ломай обратную совместимость при рефакторинге, не надо будет фиксить баги"
cupraer
19.05.2025 21:32абстрагироваться непосредственно от Clojure, а держать в фокусе идею "неразрушающего рефакторинга"
Ну вот автор говорит очень правильную вещь: идея «неразрушающего рефакторинга» — лежит на плечах сообщества. «Тут так принято», — говорит автор очередного лефтпада, и выпускает версию
5.0
, которая несовместима ни с одним предыдущим мажором. Знакомо? — А мне нет. В сообществе эликсира, например, не бывает библиотек с мажорным номером версии, превышающим2
(за наиредчайшим исключением).1.0
фиксирует API,2.0
— означает буквально «мы больше не добавляем функциональность, только фиксим баги». Я поддерживаю более десяти библиотек, так или иначе используемых сообществом, и они все до единой обратно совместимы с версией0.0.1
. Потому что так принято (ну и потому что мне лично такая идеология близка, конечно).flancer
19.05.2025 21:32Ну, у меня в приоритетах веб-приложения, включая SPA/PWA. Поэтому только JS, только хардкор!!1 :)
А по поводу обратной совместимости у меня несколько другое мнение (см. последний пункт в "С лупой на слона"). Так тоже иногда бывает принято, в других сообществах. Например, на программном уровне Magento 2 и Magento 1 - две разные Вселенные, а с точки зрения бизнес-функций - эволюция одной ¯\_(ツ)_/¯
cupraer
19.05.2025 21:32обратная совместимость — враг адаптивности
Я не согласен. Но чем больше библиотека — тем сложнее, разумеется, этого добиться без окостыливания всего вокруг. Тем не менее, обеспечение обратной совместимости мне очень сильно помогает не приклеивать к телегам пятые колёса.
tnsr
19.05.2025 21:32Сорри за оффтоп, но графики напомнили мне детские рисунки моей дочери. Типа "цветение сакуры". Так то конечно по ним сложно сходу врубиться в их смысл.
MzMz
когда я вижу статью про Clojure, то каждый раз я наблюдаю много философии, объяснений почему Clojure это круто, но при этом очень мало примеров реального кода :)
pnmv
зато, графики красивые.
cupraer
За примерами реального кода я лично хожу в гитхаб, а не на околотехнические форумы. В чем проблема открыть код упомянутой в тексте библиотеки, которую автор поддерживает и — ну я не знаю даже — …ммм… — может быть, ну… — посмотреть на код?