Сага о DRY и зависимостях
Предположим, в вашем проекте два модуля: project.foo
и project.bar
. В первый
из них вы добавили служебную функцию, например поиск элемента по
предикату. Через некоторое время то же самое понадобилось во втором модуле. Как
вы поступите?
Варианты:
- Вынесу функцию в третий модуль
project.utils
и подключу его кfoo
иbar
. - Скопирую функцию во второй модуль.
Конечно, вы и так знаете, что правильный способ — первый. DRY (Don’t Repeat Yourself), не дублируй код. Держи одну точку входа, чтобы в случае ошибки править в одном месте. Копирование — наш враг и все такое. Я тоже тоже так думал раньше, и вот, в тридцать пять лет, поменял мнение.
Теперь я просто копирую код. Да, так было можно, и вот почему.
Рассмотрим ваши модули foo
и bar
. Сейчас они не связаны друг с другом, и это
хорошо:
┌───────────────┐
│ project.foo │
└───────────────┘
┌───────────────┐
│ project.bar │
└───────────────┘
Если добавить в каждый из них по одинаковой функции, они по-прежнему не будут связаны:
┌─────────────────────┐
│project.foo │
│ │
│function zip(...) │
└─────────────────────┘
┌─────────────────────┐
│project.bar │
│ │
│function zip(...) │
└─────────────────────┘
Если вынести общую часть в условный commons
или utils
, получатся лишние
связи:
┌───────────────┐
┌─────────────────────┐ ┌───│ project.foo │
│project.util │ │ └───────────────┘
│ │◀──┤
│function zip(...) │ │ ┌───────────────┐
└─────────────────────┘ └───│ project.bar │
└───────────────┘
Конечно, всего лишь две связи не разрушат проект. Но представьте, что оба foo
и bar
зависят от других модулей, а те — от третьих. Добавляя новые зависимости
между модулями, вы запутываете проект. Чем больше файлов и импортов, тем сложнее
поддержка.
Далее: мы только создали модуль utils
или commons
. Как точно описать его
семантику? Что должно там храниться? У нас есть поиск элемента по
предикату. Если коллега добавит преобразование времени в миллисекунды, будет ли
это верным местом? А форматирование валюты? А генерация паролей?
Очевидно, utils
становится помойкой, в которую пихают что угодно. Но страшно
не это. Utils
, как раковая опухоль, расползается по проекту, и скоро каждый
второй модуль заражен нашим utils
. Далее случается вот что: одну из функций
utils
нужно улучшить с помощью сторонней библиотеки. Теперь все, кто затянут
себе utils
, транзитивно подтянут и стороннюю библиотеку, даже если в ней не
нуждались.
Модули utils
— частое явление в проектах, и нужно его обуздать. Если в проекте
уже есть подобный модуль-помойка, то я предлагаю правило — в нем можно
пользоваться только коробочными модулями. Если функция требует чужую библиотеку,
ей не место в utils
. С этим ограничением мы гарантируем, что по крайней мере
utils
не ухудшится окончательно.
Понимаю, что у каждого разработчика есть набор самописных функций, которых нет в поставке языка. У меня их пара десятков. Вот несколько:
Find-first
, поиск первого элемента по предикату. Требуется постоянно. Варианты
с filter
и first
я считаю уродливыми и терпеть не могу их комбинацию в
коде. Моя реализация:
(dеfn find-first [pred coll]
(some (fn [x]
(when (pred x)
x))
coll))
Зиппер двух и более коллекций. В отличие от zip-map
, вернет вектор векторов,
что гарантирует порядок:
(dеfn zip [coll1 coll2 & colls]
(apply mapv vector coll1 coll2 colls))
Неленивая склейка коллекций. Стандартный concat
ленив и в особых случаях им
можно отстрелить себе ногу. Ниже — простая и быстрая версия:
(dеfn concat-colls
[coll-of-colls]
(reduce into [] coll-of-colls))
Разделение словаря на два вектора ключей и значений:
(dеfn map-split [mapping]
(apply mapv vector mapping))
Список можно продолжить, но вы поняли: этих функций нет в коробке, но они
нужны. С их помощью мы именуем сложные действия и делаем код понятнее. Конечно,
и без всякого map-split
можно написать в коде:
(let [[user-keys user-vals] (apply mapv vector user-map)]
...)
Но получится каша, которую трудно понять. Что такое (apply mapv vector ...)
? Я
должен выполнить этот код в уме или репле, прежде чем станет ясно. Это
недопустимо — при чтении кода он должен быть ясен сразу. Поэтому опытный
программист выносит подобные комбо в именованные функции. Не чтобы сэкономить
место, а чтобы назначить операции семантику.
В работе я постоянно использую эти и другие функции. Раньше я как и все выносил
их в условный commons
и подключал где только можно. Теперь просто копирую — не
парит. Обычно мой код начинается с нескольких служебных функций и макросов,
содранных с других модулей, а дальше следует логика.
И вы знаете, это прекрасный подход. Если нужен слегка другой вариант функции, я
меняю его прямо здесь, а не в commons
. Таким образом я не волнуюсь, что зацеплю
остальных потребителей этой функции, потому что их нет.
Возвращаясь к вопросу в начале статьи — я бы смело скопировал функцию во второй модуль и продолжил работу. Конечно, в ревью могут набежать адепты чистоты кода. Основная их претензия в следующем: если функцию потребуется изменить, придется делать это в двух местах, а в случае с общим модулем — в одном месте.
Справедливо, но вот что на это можно ответить. Прежде всего, какова вероятность, что простую и чистую функцию придется переписывать? Как правило, функции просты и проверены временем. Может ли адепт DRY дать какой-то прогноз? Ответ очевиден — нет. Аргументы из серии “а что, если” относятся к демагогии. Случиться может что угодно: и ядерная война, и ликвидация фирмы, и покупка ее новым владельцем с переписыванием кода на PHP. Готовиться к каждому хоть сколько вероятному событию означает распылять внимание и терять фокус.
Кроме того, предложение улучшить код “на всякий случай” нерационально. Это выбор из двух. Первое — ничего не делать сейчас и, возможно, доработать в будущем. Второе — править сейчас, хотя в будущем, возможно, это не пригодится. Разумный человек выберет первый вариант. И не забывайте, что задача адепта DRY — просто написать комментарий, чтобы дать о себе знать. А работать придется вам.
Вообще, в чем ваша конечная цель как программиста? Не в том, чтобы писать понятный код и говорить на созвонах. Цель — создавать продукт в разумные сроки. Если в проекте встречаются повторы кода, но продукт развивается по графику — это лучшее, что можно пожелать. Гораздо хуже, когда модули полны перекрестных зависимостей, и проект становится хрупким.
Когда-то я работал в большом проекте на Питоне. У нас были классы, идеологически верное ООП, все по заветам умных людей. Навсегда запомнил такой случай. В проекте была сущность, которую описывал класс на три экрана. Нам велели добавить в продукт похожую сущность, но с некоторыми отличиями. Если бы задачу дали мне, я бы унаследовал новый класс от прежнего и переопределил нужные методы. ООП, наследование, все дела.
Но тимлид предугадал такой сценарий и забрал задачу себе. Он скопировал класс в новый модуль и уже там изменил поля и методы. Помню, как подкатывал к нему с претензией: ты нарушаешь принципы ООП, пятое-десятое. Но тимлид умел брать ответственность за решения. Он все сделал правильно, а я, 25-летний дурачок, тогда этого не понял.
Помните, что каждое правило имеет область определения: где, когда и с кем. Начинающие обычно закрывают на это глаза: сложно, приходится думать. Гораздо легче свести правило к догме: всегда и только так. Увы, это не работает в больших масштабах. Дублирование кода — компромисс, на который приходится идти, чтобы бороться с ростом связей внутри проекта.
Конечно, нельзя дублировать части, которые относятся к бизнес-логике
приложения. Например, расчет комиссии, конвертация валют, авторизация. Но
опытный программист ни за что не поместит подобный код в utils
. Ему место в
модулях project.currency
, project.billing
или project.auth
. Если у вас
обратная ситуация — это грубая ошибка.
Опасность лишних связей видна еще лучше в мульти-проектах. Если простыми словами, это головной проект, внутри которого дочерние проекты. Они наследуют основные параметры головного, а последний может ими пакетно управлять. Например, прогнать тесты для всех дочерних проектов, собрать их, выкатить релиз и так далее. Но главная задача мульти-проекта — сделать так, чтобы потребители могли использовать лишь его малую часть, не затягивая все целиком.
Вот как это работает в проекте, над которым тружусь прямо сейчас. Мы интегрируемся с неким сторонним сервисом. На первый взгляд задача банальна — нужно дергать чужую REST-апишку, конвертировать данные в наш формат и возвращать клиенту. Но вот что происходит на практике.
- Сначала пишем низкоуровневый HTTP-клиент для сервиса.
- Оборачиваем CRUD операции в некий Entity-фреймворк, который активно используем в остальных проектах.
- Пишем HTTP-обработчик, который подключим в отдельное приложение.
- Для биллинга пишем хранилище сущностей, которые создаем в том сервисе. Хранилище включает в себя таблицу в Кассандре и несколько компонентов.
- Пишем
reconciler
— периодическую задачу, которая выгребает данные из сервиса и записывает к нам.
Если валить все в одну кучу, это будет удобно нам, но не другим
командам. Например, соседней команде нужны только CRUD-операции. Если они
затянут проект целиком, то получат кассандру, reconciler
и прочее
барахло. Конечно, с помощью параметра :exclude
можно указать, от чего
отказаться. Но практика показывает, что к нему прибегают редко — только когда в
консоли появляются предупреждения о конфликтах версий. Поэтому по умолчанию
библиотеки затягивают целиком.
Правильно разделить код на под-проекты: service-client
, service-entities
,
service-storage
, service-reconciler
и так далее. Вести для них единую
систему версий. Тогда ребята, которым нужны CRUD-сущности, добавят себе
[service-entities "0.1.3"]
и получат только то, что им нужно.
Теперь внимание: в дочерних проектах service-client
и service-reconciler
встречаются одинаковые функции типа zip
, find-first
и другие. Как с ними
быть? Просто воткнуть зависимость service-reconciler
от service-client
нельзя — это будет катастрофа. Мы же намеренно разносили их, а теперь мешаем в
кучу.
┌─────────────────────┐
│service-client │
│ │
│function zip(...) │
└─────────────────────┘
▲
│
│ ┌─────────────────────┐
│ │ │
└────│service-reconciler │
│ │
└─────────────────────┘
Более мягкий вариант — собрать дочерний проект service-utils
и подключить ко
всем остальным.
┌─────────────────────┐
│ │
┌───────│service-client │
│ │ │
▼ └─────────────────────┘
┌─────────────────────┐
│service-util │
│ │
│function zip(...) │
└─────────────────────┘
▲
│ ┌─────────────────────┐
│ │ │
└───────│service-reconciler │
│ │
└─────────────────────┘
Но опыт показывает, что это слабый ход. Вы получите лишние связи уже не между
модулями, а библиотеками. Это затрудняет разработку: если изменится служебная
функция в service-utils
, нужно собрать новую версию пакета, обновить его
версию в service-client
, service-reconciler
и прочих, прогнать тесты… Даже
текстом тяжело описать, что для этого придется, не говоря уж о практике.
Именно поэтому я осторожно отношусь к чужим библиотекам, в названии которых
встречается utils
или commons
. Я всегда смотрю их исходный код. Мое
обязательное требование — библиотека не должна зависеть от других сторонних
либ. Если мне нужна одна функция на десяток строк, а вместе с ней я затягиваю
еще чьи-то поделки, то так не пойдет.
В последнее время я поступаю нагло: просто копирую код из чужих commons
и
utils
. Меня не волнуют вопросы лицензии и этики. Мне так удобнее и
спокойней. Я скопировал немного кода и точно знаю, что:
- я не добавил лишних зависимостей.
- Мне не нужно проверять changelog чужой либы перед тем, как обновлять ее версию.
- Код не пропадет из-за того, что автор удалил его, кому-либо передал или продал права.
Спокойно мне — лучше проекту.
Напоследок немного Рича Хикки. Не то чтобы я его фанат, но на одну цитату запал конкретно, а именно: Design is about keeping things apart. Дизайн — это грамотное разделение вещей. Не связь, а именно разделение. Мой опыт подтверждает: когда работаешь над чем-то сложным, части норовят слиться в одно целое. Ты слишком занят и думаешь: поправлю потом. Затем нужно поменять в одном месте так, чтобы не задеть остальные, и привет — по сложности это как разделить сиамских близнецов. В моем понимании дизайн грамотный, когда части работают как единое целое, но каждый компонент легко настроить и заменить.
Тезис про разделение справедлив для мира в целом. Любая система, неважно, что это — организм, общество, государство — развивается успешно лишь тогда, когда предоставляет своим частям автономию. В неблагополучное время, когда речь идет о выживании, компоненты теряют полномочия. В такие моменты все решает центр, а некоторые компоненты ликвидируются. Тут не до разделения, а лишь бы выпустить релиз.
Но для процветания нужно разделение. Хоть кода в проекте, хоть людей в группе, хоть общественных институтов в государстве.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter
Snake19, 22nd May 2021, link
Начали с foo/bar, а закончили современным устройством России. Спасибо)
Andrey Saksonov, 27th May 2021, link
Тесты на эти функции тоже дублировать в каждом модуле?
Ivan Grishaev, 27th May 2021, link , parent
Тесты для них не нужны. Один раз проверил в репле и норм.