Предположим, в вашем проекте два модуля: project.foo и project.bar. В первый из них вы добавили служебную функцию, например поиск элемента по предикату. Через некоторое время то же самое понадобилось во втором модуле. Как вы поступите?

Варианты:

  1. Вынесу функцию в третий модуль project.utils и подключу его к foo и bar.
  2. Скопирую функцию во второй модуль.

Конечно, вы и так знаете, что правильный способ — первый. 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. Дизайн — это грамотное разделение вещей. Не связь, а именно разделение. Мой опыт подтверждает: когда работаешь над чем-то сложным, части норовят слиться в одно целое. Ты слишком занят и думаешь: поправлю потом. Затем нужно поменять в одном месте так, чтобы не задеть остальные, и привет — по сложности это как разделить сиамских близнецов. В моем понимании дизайн грамотный, когда части работают как единое целое, но каждый компонент легко настроить и заменить.

Тезис про разделение справедлив для мира в целом. Любая система, неважно, что это — организм, общество, государство — развивается успешно лишь тогда, когда предоставляет своим частям автономию. В неблагополучное время, когда речь идет о выживании, компоненты теряют полномочия. В такие моменты все решает центр, а некоторые компоненты ликвидируются. Тут не до разделения, а лишь бы выпустить релиз.

Но для процветания нужно разделение. Хоть кода в проекте, хоть людей в группе, хоть общественных институтов в государстве.