Дупликация против связки: как определить, когда следует использовать общий код
Узнайте, почему объединение похожих компонентов React или сервисов NestJS может обойтись дороже, чем их копирование, и воспользуйтесь тремя вопросами, чтобы определить, когда абстракция действительно необходима.
Источники: I Don’t Want My Code to Be Reusable
Большинство разработчиков учатся рассматривать повторяющийся код как дефект: обнаруживают два похожих компонента, извлекают общий элемент и продолжают работу. Такая реакция часто бывает правильной, но она скрывает определенные издержки, которые проявляются лишь спустя месяцы, когда общий элемент приходится использовать для пользователей с изменившимися требованиями. Если рассматривать это решение как выбор между двумя видами издержек — дублированием и связностью, — это помогает сформулировать несколько вопросов для определения того, какие издержки следует принять во внимание при работе с кодом React, React Native и бэкенда.
Основная мысль проста, но ее легко неправильно понять: повторение не является добродетелью, однако в некоторых ситуациях дублирование дешевле, чем связность. Далее приведен гид по распознаванию таких ситуаций.
Как аккуратный общий компонент превращается в язык конфигурации
Начнем с двух компонентов, отображающих изображение человека. Один из них предназначен для обычных пользователей:
<UserAvatar user={user} />
Второй вариант предназначен для инструкторов:
<TrainerAvatar trainer={trainer} />
В первый день они практически неотличимы. Каждый из них отображает:
- изображение профиля
- замену при отсутствии изображения
- идентичные размеры
- статус загрузки
Очевидный вывод заключается в том, что один компонент должен выполнять обе функции, поэтому появляется универсальный аватар:
<Avatar
imageUrl={...}
fallback={...}
size="medium"
/>
Это кажется явной победой: меньше кода, одно место для устранения ошибок. Но затем продукт развивается. Пользователям нужен временный элемент, соблюдающий настройки конфиденциальности. Инструкторам нужна бейдж-индикация статуса. Пользователи могут использовать инициалы в качестве замены. Инструкторы должны показывать, свободны ли они в данный момент. Каждый запрос поступает в общий компонент в виде дополнительного параметра:
<Avatar
imageUrl={...}
fallback={...}
size="medium"
showInitials={...}
showVerification={...}
showAvailability={...}
privacyMode={...}
/>
Постоянно появляются новые требования, и каждое из них становится отдельным фактором, влияющим на функционал. В итоге «универсальный» аватар должен учитывать информацию о пользователях, тренерах, правилах конфиденциальности, процедурах верификации, доступности, а также различную бизнес-логику, не связанную с рисованием круга с изображением внутри. Технически он по-прежнему может использоваться повторно, но уже не является простым в использовании. Сложность не исчезла — она просто сосредоточилась в одном файле, от которого теперь зависит каждый потребитель этого компонента. Если этот паттерн эксплозии свойств кажется вам знакомым, статья «Когда повторно используемые компоненты React дают сбои: паттерн эксплозии свойств и способы его решения» более подробно рассматривает процесс рефакторинга таких компонентов.
Обмен кодом означает обмен будущим
Редко обсуждается тот факт, что извлечение совместного кода — это не просто решение, связанное с реализацией. Оно объединяет судьбы потребителей этих компонентов. Когда компонент A и компонент B оба зависят от одной абстракции, любые изменения в A могут нарушить работу или изменить структуру B. Повторное использование создаёт взаимосвязь, а взаимосвязи представляют собой своего рода связывание.
Это меняет вопрос, который стоит задать. На вопрос «Могут ли эти два элемента использовать общий код?» почти всегда можно ответить «да». Лучший вопрос звучит так: должны ли эти два элемента иметь общее будущее?
Два блока кода могут сегодня быть полностью идентичны по тексту, но при этом относиться к не связанным частям домена. Их реализация совпадает; однако причины изменений могут отличаться. Именно эта разница гораздо лучше, чем простое подсчёт количество дублирующихся строк, позволяет предсказать затраты на техническое обслуживание. Она тесно связана с принципом единственной ответственности, который обычно формулируется как «модуль должен иметь одну причину для изменения», причем под причиной изменения понимается группа людей, чьи требования его вызывают.
Когда дублирование маркировки обеспечивает независимость
Рассмотрим карточку пользователя:
<UserCard />
и платёжную карту:
<PaymentCard />
В настоящее время обе отображают одну и ту же структуру:
<div className="card">
<h3>{title}</h3>
<p>{description}</p>
</div>
Естественным следующим шагом становится создание общей карточки:
<Card />
Предположим теперь, что карточка, предназначенная для пользователя, часто перерабатывается, в то время как карточка для оплаты ограничена совершенно отдельными требованиями к продукту и соответствию нормам. Совпадение структуры маркировки — это случайность; бизнес-значение у них не совпадает. Разделение этих двух элементов обходится в несколько дублирующих строк JSX. Взамен каждая концепция получает пространство для развития без необходимости переговоров. Такое дублирование приносит конкретную выгоду: возможность изменять одну из частей без необходимости согласования с создателями другой. С такой точки зрения две отдельные компоненты — это не проявление небрежной архитектуры. Это может быть осознанным разделением функций.
Для React существует важная нюанс. Чисто визуальный элемент — оформленный контейнер без особого смысла в рамках конкретной области применения — часто можно безопасно использовать совместно, поскольку причиной его изменений является система дизайна, а не функциональность продукта. Однако проблема возникает, когда такой элемент начинает включать в себя решения, связанные с конкретной областью применения, которые относятся к одному пользователю.
Спрашивайте, почему два элемента одинаковы, а не как их объединить
При столкновении с повторением первым вопросом, который возникает у нас, обычно является «Как мне это абстрагировать?». Более полезной последовательностью вопросов будет:
- Почему эти два элемента сейчас одинаковы?
- Останутся ли они такими же и по той же причине?
Второй вопрос сложнее, потому что он заставляет нас прекратить сравнивать синтаксис и начать сравнивать цели.
Идентичные реализации могут отражать разные концепции
Возьмем два вспомогательных инструмента форматирования: один для пользователей:
formatUserName(user)
и другой для инструкторов:
formatTrainerName(trainer)
Предположим, что оба в настоящее время содержат одинаковый текст:
return `${firstName} ${lastName}`;
Тогда их объединяют в один вспомогательный инструмент:
formatFullName(person)
Это кажется безопасным, пока правила не начинают отличаться. При формировании имён пользователей может потребоваться учитывать:
- предпочтительные имена
- настройки конфиденциальности
- правила локализации
При формировании имён инструкторов может потребоваться учитывать:
- профессиональные титулы
- сертификаты
- специфические правила отображения имён
Изначальные функции могли бы развиваться по отдельным направлениям без проблем. Однако универсальный вспомогательный инструмент предполагает, что пользователи и инструкторы — это один тип «человека» для целей формирования имён, что уже не соответствует действительности. Это менее очевидный риск абстрагирования:
Абстракция — это не просто общий код; она содержит информацию о том, как структурирована соответствующая область.
Когда эта информация неверна, абстракция вводит в заблуждение следующего разработчика, который справедливо предполагает, что любой элемент с именем formatFullName применим ко всем пользователям в системе.
Последствия преждевременной абстракции проявляются позже
Преждевременная абстракция кажется привлекательной, потому что все видимые показатели улучшаются сразу после её внедрения. Размер файла уменьшается примерно с:
100 lines
до:
60 lines
Дублирование исчезает, пул-запрос выглядит более аккуратным, а сама абстракция кажется изящной. Однако издержки откладываются. Через год кому-то нужно изменить поведение одного компонента, и он обнаруживает, что этот общий компонент используется ещё в семи местах. Чтобы не рисковать повредить остальные компоненты, он создаёт новую ветку:
if (variant === "A") ...
else if (variant === "B") ...
else if (variant === "C") ...
Затем появляются ещё свойства, ещё флаги, пути совместимости для старых экранов. Абстракция сохраняется, но её концептуальная простота — нет. Именно так в кодовых базах появляются компоненты с интерфейсом, похожим на этот:
<UniversalThing
mode="..."
variant="..."
type="..."
compact
showHeader
showFooter
enableSomething
disableSomethingElse
/>
В такой момент компонент перестаёт быть абстракцией и превращается в небольший язык конфигурации для нескольких независимых сценариев использования. Каждая комбинация этих свойств представляет собой состояние, о котором необходимо думать, причём большинство комбинаций никогда не тестировались.
Повторное использование может затруднить изменения, а не упростить их
Ирония заключается в том, что общий код создаётся для того, чтобы сделать изменения дешевле, однако чрезмерное использование общего кода зачастую делает их дороже. Причина в радиусе поражения: наборе элементов, на которые может повлиять одно изменение.
До появления абстракции каждая функциональность имела свой собственный компонент:
Feature A → Component A
Feature B → Component B
После этого каждая функциональность проходит через один общий компонент:
Feature A ─┐
Feature B ─┼→ Shared Component
Feature C ─┘
Во второй диаграмме меньше дублирующегося кода, но более сложная сеть зависимостей. Поэтому полезное сравнение — не «какая версия имеет меньше дублирования?», а «какая версия делает стоимость будущих изменений более предсказуемой?» Когда изменение в функциональности B должно пройти тесты на регрессию по функциональностям A и C, общий компонент делает изменения менее предсказуемыми, даже если он сокращает объем кода.
Почему React способствует преждевременному совместному использованию компонентов
React делает извлечение кода практически без препятствий. Появляется кнопка:
<Button />
и она становится повторно используемой. Затем появляется карточка:
<Card />
Затем модальное окно:
Modal />
Затем поле формы:
FormField />
Затем пользовательский хук:
useSomething()
Вскоре появляется внутренняя библиотека компонентов, используемая во всем приложении. Большая её часть действительно ценна. Есть элементы, которые однозначно следует делиться:
- кнопка, соответствующая стилистике дизайна приложения
- примитивы доступности низкого уровня, такие как управление фокусом или доступные метки
- концепции домена, которые действительно стабильны
Само повторное использование не является проблемой. Проблема в том, чтобы использовать что-то только потому, что оно похоже. Примитивы интерфейса, предназначенные для совместного использования, хорошо работают, если они не связаны с бизнес-правилами; этот момент также рассматривается в статье по проектированию компонентов на основе принципа ответственности, а не повторного использования.
React Native умножает количество состояний
Мобильные приложения добавляют ещё один измерение. Два экрана могут выглядеть похоже, но иметь совершенно разные требования к жизненному циклу. Компонент, который хорошо работает на одном экране, позже может потребовать учета следующего:
- перехода приложения в фоновый режим
- поведения клавиатуры
- потери сетевого соединения
- различий между платформами iOS и Android
- разрешений
- глубоких ссылок
- различий в размерах устройств
- состояния навигации
- режима офлайн
Если с самого начала всё делается универсальным, общий компонент в итоге должен учитывать каждую среду, в которой он может работать. Он становится «гибким», но гибкость имеет свою цену: каждый новый вариант умножает количество возможных состояний. Каждое из этих состояний — это то, что человек в конечном итоге должен понимать, тестировать и обслуживать. Два варианта создают четыре комбинации; пять — тридцать две.
Сервисы бэкенда попадают в ту же ловушку
Это проблема, касающаяся не только фронтенда. Представьте два сервиса NestJS:
UserService
TrainerService
Изначально они могут предоставлять одинаковые операции:
create()
findById()
update()
delete()
Генерический базовый класс кажется привлекательным вариантом:
BaseService<T>
Иногда это срабатывает и позволяет сократить объем кода. Но как только бизнес-правила для пользователей и инструкторов начинают отличаться, базовый класс начинает собирать исключения. Сначала происходит проверка типа:
if (entityType === "user") ...
затем — хук, который можно переопределить перед обновлением:
protected beforeUpdate(...)
а потом — еще один хук после создания:
protected afterCreate(...)
Постепенно появляется целый набор точек расширения, единственная функция которых — заставить один универсальный сервис вести себя по-разному в разных доменах. Это позволяет заменить дублирующийся код на условную сложность, с которой обычно гораздо труднее разбираться, поскольку для понимания поведения той или иной сущности теперь необходимо читать базовый класс, его хуки и все переопределения вместе. Если это звучит знакомо, обсуждение структурирования доменов NestJS с использованием правил DDD предлагает дополнительный взгляд на то, где должны находиться границы.
Это не аргумент в пользу копирования-вставки
Заключение о том, что дублирование — это хорошо, было бы таким же упрощенным. У дублирования есть реальные издержки:
- Если десять систем независимо реализуют одно и то же бизнес-правило, для исправления одной ошибки может потребоваться десять изменений, а её пропуск приведёт к несогласованной работе.
- Если по двадцать компонентов каждый реализует одинаковое поведение с точки зрения доступности, поддержание их согласованности становится крайне сложным.
- Если несколько приложений опираются на один и тот же стабильный контракт, совместное использование этого контракта имеет огромную ценность.
На самом деле речь идёт о чем-то более конкретном: дупликация и взаимосвязанность — это два разных вида затрат. Хорошая инженерия подразумевает выбор тех затрат, которые соответствуют конкретной проблеме, а не постоянное минимизирование только одних из них.
Три вопроса перед созданием абстракции
Когда два фрагмента кода выглядят похожими, сделайте паузу и рассмотрите эти вопросы перед их слиянием.
Изменяются ли они по одной и той же причине?
Это самый важный момент. Если требования к продукту склонны меняться одновременно для элементов A и B, то совместное использование вероятно целесообразно. Если же A меняется под влиянием одного бизнес-фактора, а B — под влиянием другого, то совпадение текущей реализации не является убедительным доказательством того, что эти элементы связаны между собой.
Означают ли они одно и то же?
Код может быть идентичным, но при этом иметь разную семантику, и именно семантика постоянно развивается. Показатель цена и сумма на счету баланс могут быть просто числами, но это не оправдывает объединения их в один псевдоним во всех случаях:
type Amount = number;
Формат представления совпадает, но значение — нет. Цена может включать информацию о валюте и налоговых правилах, а баланс — о лимитах на чрезвычайные операции. Сохранение их как отдельных понятий, даже если сегодня оба представлены числами, позволяет легко учитывать эти различия.
Это устраняет дублирование или просто удаляет строки кода?
Это разные варианты исхода. Хорошая абстракция устраняет дублирование концепций. Плохая же лишь сокращает размер файлов. Вынесение 30 строк из двух компонентов в помощник, содержащий 40 строк и восемь свойств, не обязательно улучшает ситуацию; сложность просто перемещается и требует дополнительного ухода в виде интерфейса.
Целенаправленное дублирование — законный выбор
Разумно оставлять два фрагмента кода отдельными, когда они:
- маленькие
- простые
- склонны развиваться независимо
- не обеспечивают защиту критической неизменной величины
- не являются частью общей концепции домена
Причина оставлять их в покое заключается не в отсутствии навыков работы с абстракциями, а в понимании того, какова будет цена такой абстракции. Способность рассматривать повторения и решить «пока ещё нет» — это признак зрелости, а не лени. Не каждое повторение является техническим долгом. Иногда это просто повторение.
Когда дублирование становится сигналом тревоги
Вторая сторона тоже имеет такое же значение. Некоторое дублирование является явным сигналом к консолидации:
- одно и то же сложное бизнес-правило, скопированное в нескольких местах
- три приложения, каждое из которых реализует один и тот же процесс аутентификации
- несколько команд, которые должны соблюдать один контракт API
- изменение правила, требующее запоминания десяти разных мест его применения
В таких случаях правильный вопрос звучит так: какие знания дублируются? Это имеет гораздо большее значение, чем количество повторяющихся строк, потому что то, что следует избегать дублирования, — это знания, а не текст.
DRY всегда касался знаний
Фразу «Не повторяй себя» часто интерпретируют как «никогда не писать один и тот же код дважды». Однако первоначальное формулирование в книге The Pragmatic Programmer касается знаний: каждый фрагмент знаний должен иметь в системе единственное, авторитетное представление. Это разные правила.
Два компонента могут содержать похожий JSX без необходимости дублирования бизнес-логики. В то же время две совершенно разные функции могут каждая кодировать одно и то же бизнес-правило, например порог скидки, жестко заданный как в компоненте оплаты, так и в валидаторе на серверной стороне. Второй случай является опасным, поскольку приводит к тихому ухудшению качества кода. Поэтому вместо того, чтобы спрашивать, можно ли что-то сделать повторно используемым, стоит спросить где должна находиться эта информация.
Пусть абстракция сама найдет свое место
Не обязательно создавать абстракцию сразу при появлении дублирования. Допустить наличие повторений на некоторое время и наблюдать за тем, как эти копии развиваются, — это валидная стратегия. Если второй и третий случаи использования продолжат развиваться в одном направлении, правильная форма абстракции станет очевидной. Таким образом, общее поведение будет открыто, а не изобретено.
Эта разница значительна. Абстракция, полученная на основе трех действительно похожих случаев использования, обычно гораздо надежнее той, что создается на основе одного случая использования с целью учета двух гипотетических будущих случаев. Именно это лежит в основе известной гедулики «правила трех». Другими словами:
Абстракция должна основываться на том, каким вы сейчас воспринимаете код, а не на том, каким он, по вашему мнению, может стать в будущем.
Цель — гибкий дизайн, а не переиспользуемый код
Само по себе переиспользование не является полезным показателем качества архитектуры. Лучшими показателями являются:
- насколько легко понять код
- насколько изолирована типичная изменение
- насколько предсказуемы последствия изменения
- остаются ли бизнес-концепции четко разделены
- расположены ли границы в разумных местах
Иногда эти критерии приводят к изящной общей абстракции. Иногда они приводят к двум практически идентичным компонентам, расположенным рядом, и это может быть лучшим дизайном, поскольку эти компоненты никогда не были одним и тем же. Они просто выглядят похожими сегодня.
Основные выводы
- Общее использование кода создает зависимость между его потребителями; рассматривайте каждое извлечение кода как решение о том, что их будущее связано.
- Оценивайте кандидатов на роль абстракций по причинам их изменения и значению, а не по текстовому сходству.
- Флаги Prop, ветки с вариантами и переопределяемые хуки — это признаки того, что общий элемент используется в несвязанных областях.
- Свободно дублируйте небольшие, простые коды, которые развиваются независимо; объединяйте дублирующиеся знания, такие как бизнес-правила, контракты и схемы безопасности.
- Предпочитайте абстракции, выявленные в ходе реальных сценариев использования, тем, что были разработаны для вымышленных случаев.
- Прежде чем объединять две похожие части кода, спросите себя, создаете ли вы возможность повторного использования или просто связь между элементами, и должна ли эта связь сохраняться дольше, чем сам код, который вы сохраняете.