open atlas
↑ К треку
Паттерны и качество кода CP · 08 · 03

Расходящееся изменение и стрельба дробью

Расходящееся изменение и стрельба дробью — зеркальные запахи нарушения SRP, которые читаются из истории git, а не из спора об обязанностях. Один модуль, правимый по многим причинам, нужно разбить; одно изменение, задевающее много модулей, — собрать.

CP Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

«Единственная ответственность» — самая спорная фраза в этой профессии. Два инженера смотрят на один и тот же класс, один говорит, что у него одна обязанность, другой — что четыре, и ни один не может доказать свою правоту, потому что «ответственность» звучит как свойство, которое решаешь умозрительно. Это не так. Есть способ закрыть спор фактами, и факты уже лежат в твоём репозитории.

Прогони git log по файлу и смотри, почему он меняется. Ответственность — не существительное, которое ты назначаешь; это ось изменения — причина, по которой файл правят. Когда один файл правят по многим несвязанным причинам или одна причина вынуждает править много файлов, история git показывает это раньше любого обсуждения дизайна. Эти два паттерна — старейшие запахи в каталоге, и они зеркальны: расходящееся изменение и стрельба дробью.

Цель

После этого урока ты можешь дать определение расходящемуся изменению (один модуль правят по многим причинам → низкая связность → разбить его) и стрельбе дробью (одна причина вынуждает править много модулей → высокая связанность → собрать её); диагностировать каждое по паттернам совместных изменений в истории git, а не из абстрактного спора; выбрать верное направление рефакторинга для каждого; и распознать режим отказа, когда настоящую сквозную функциональность принимают за стрельбу дробью и сминают в god-утилиту.

1

Расходящееся изменение: один модуль правят по многим несвязанным причинам. Добавляешь платёжного провайдера — меняется этот класс. Меняются налоговые правила — меняется этот класс. Меняется шаблон письма — снова меняется этот класс. Три разных направления требований, и все приземляются в один файл. Это расходящееся изменение — симптом низкой связности, потому что модуль собирает в кучу обязанности, которые варьируются независимо.

// OrderService.ts — задеваемый тремя несвязанными силами
class OrderService {
  charge(order: Order) { /* специфика Stripe API — меняется, когда меняются платежи */ }
  computeTax(order: Order) { /* правила НДС — меняются, когда меняется налоговый закон */ }
  sendReceipt(order: Order) { /* тело HTML-письма — меняется, когда меняется маркетинг */ }
}

Улика — в истории: один и тот же файл появляется в коммитах, чьи сообщения не имеют друг к другу никакого отношения — «перейти на Adyen», «добавить НДС ЕС», «новый дизайн чека». Лечение — разбить по осям изменения, чтобы у каждого нового требования был один очевидный дом.

2

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

// ОДНО И ТО ЖЕ правило (как рисовать деньги) переписано в пяти местах
cart.ts:      const label = currency === 'USD' ? '$' + amt : '€' + amt;
checkout.ts:  const label = currency === 'USD' ? '$' + amt : '€' + amt;
invoice.ts:   const label = currency === 'USD' ? '$' + amt : '€' + amt;
// ...и ещё два

Улика снова в истории: один коммит (или, хуже, несколько коммитов, гоняющихся за одним багом) задевает разброс файлов, у которых не должно быть ничего общего. Лечение — собрать разбросанные осколки за одной границей, чтобы следующая валюта была одной правкой.

3

Это эмпирические запахи SRP — их читают по совместным изменениям, а не спорят о них. Принцип единственной ответственности обычно формулируют как «у класса должна быть одна причина для изменения». Это определение операциональное, а не философское: причина для изменения наблюдаема. Построй карту совместных изменений — для каждой пары файлов (или для множества причин коммитов каждого файла): как часто они меняются вместе и почему?

// грубый сигнал совместного изменения из истории (концептуально)
// git log --name-only --pretty=format:%s  →  группируй файлы по коммиту
// расходящееся изменение:  один файл, много несвязанных тем коммитов
// стрельба дробью:         одна тема коммита, много несвязанных файлов
  • Расходящееся изменение = один файл, высокое разнообразие причин изменения. Связность низкая; файл делает несколько работ. Разбей его.
  • Стрельба дробью = одна причина изменения, высокое разнообразие задетых файлов. Связанность высокая; одна работа размазана. Собери её.

Вот почему запахи бьют абстрактный спор: два сеньора могут вечно спорить, есть ли у OrderService «одна обязанность», но не могут спорить, что его история показывает три независимые оси изменения. Репозиторий — судья.

4

Направление рефакторинга для каждого противоположное — и выбрать неверно значит сделать хуже. Расходящемуся изменению нужно разделение: Extract Class / Move Method, пока каждый модуль не станет варьироваться по одной причине. Стрельбе дробью нужно собирание: Move Method / Inline / Combine, пока каждая причина не заживёт в одном модуле. Они тянут в противоположные стороны, поэтому диагноз должен идти первым.

// расходящееся изменение → РАЗБИТЬ (поднять связность)
class PaymentGateway { charge(order: Order) {/* … */} }
class TaxCalculator  { computeTax(order: Order) {/* … */} }
class ReceiptMailer  { send(order: Order) {/* … */} }

// стрельба дробью → СОБРАТЬ (снизить связанность)
// money.ts — единственное место, где живёт правило «рисовать деньги»
export function formatMoney(amt: number, currency: Currency): string { /* … */ }
// cart/checkout/invoice теперь все вызывают formatMoney(amt, currency)

Если «починить» расходящееся изменение собиранием, построишь god-класс побольше. Если «починить» стрельбу дробью разбиением, разбросаешь обязанность ещё сильнее. Запах называет лекарство; в этом и весь смысл того, чтобы называть их по отдельности.

Разбор примера

Прочитай запах из лога, потом выбери лекарство. Допустим, у NotificationCenter.ts такая история:

a1c  "поддержка SMS через Twilio"
b2d  "новый текст welcome-письма"
c3e  "push-уведомления для мобильных"
d4f  "rate-limit SMS, чтобы избежать скачков трат"
e5a  "редизайн футера письма"

Пять коммитов, три независимые причины (транспорт SMS, контент письма, транспорт push). Это расходящееся изменение: один файл меняется по несвязанным силам, низкая связность. Лечение — разбить по оси изменения:

class SmsChannel   { send(msg: Message) {/* Twilio + rate limit */} }
class EmailChannel { send(msg: Message) {/* шаблон + футер */} }
class PushChannel  { send(msg: Message) {/* мобильный push */} }

Теперь обратный случай. Ты берёшься добавить канал «Slack» и обнаруживаешь, что коммит должен задеть UserSettings.ts (флаг notifyBySlack), OnboardingFlow.ts (шаг согласия на Slack), AdminDashboard.ts (колонку Slack) и EventDispatcher.ts (ветку Slack) — четыре файла, одно концептуальное изменение. Это стрельба дробью: решение «какие каналы существуют» размазано по модулям. Лечение противоположное — собрать реестр каналов в одно место, к которому обращаются остальные:

// channels.ts — единственный источник истины о том, «какие каналы существуют»
export const CHANNELS = [SmsChannel, EmailChannel, PushChannel, SlackChannel];
// settings, onboarding, admin, dispatcher все выводятся из CHANNELS — добавить один = одна правка

Одна и та же кодовая база, два разных запаха, два противоположных лекарства. Лог сказал тебе какое.

Почему это работает

Почему доверять совместным изменениям, а не рассуждениям об обязанностях? Потому что обязанности-как-существительные бесконечно обсуждаемы — всегда можно доказать, что класс «на самом деле» делает одну вещь на каком-то уровне абстракции («он управляет заказами»). Что доказать нельзя — что файл появляется в коммитах, движимых независимыми бизнес-силами. Совместное изменение — это поведенческое определение связанности и связности: то, что меняется вместе, связано; модуль, чьи внутренности меняются по одной причине, связен. Инструменты вроде code-maps, агрегации git log --name-only или метрик связности коммитов показывают это напрямую — потому senior-ревьюеры тянутся к истории, когда спор о дизайне заходит в тупик. Принцип реален; история — просто то место, где он становится измеримым.

Частая ошибка

Опасный режим отказа: принять законную сквозную функциональность за стрельбу дробью и переусердствовать с централизацией. Логирование, проверки авторизации, метрики и трассировка действительно появляются во многих модулях — это их природа, а не запах. Если ты видишь «логирование задето в 30 файлах» и реагируешь изобретением god-объекта CoreUtils, который импортирует каждый модуль, — ты обменял рассеянную заботу на единственную зависимость с высоким fan-in, которая теперь связывает всё с одним волатильным файлом, и каждая правка логирования становится своей стрельбой дробью. Различитель: стрельба дробью — это одна обязанность, случайно разбросанная (новая валюта должна быть одной правкой, но не является). Сквозная функциональность — это один аспект, по природе применимый везде (каждый обработчик логирует). Правильный инструмент для второго — тонкий стабильный шов: middleware, декоратор, аспект, контекст — а не god-утилита, проглатывающая несвязанные хелперы. Собирай обязанности; не централизуй всё, что просто часто появляется.

Проверь себя
Викторина

Один файл появляется в коммитах с заголовками «добавить Apple Pay», «исправить округление НДС» и «новый макет PDF-инвойса». Какой это запах и каково лечение?

Итог

Расходящееся изменение и стрельба дробью — зеркальные запахи нарушения SRP, которые читают из истории git, а не доказывают умозрительно. Расходящееся изменение — один модуль правят по многим несвязанным причинам — низкая связность — и лечение в том, чтобы разбить по осям изменения, дав каждому требованию один дом. Стрельба дробью — одно логическое изменение вынуждает править много модулей — высокая связанность — и лечение противоположное: собрать разбросанную обязанность за одной границей, чтобы следующий случай был одной правкой. Поскольку они тянут в противоположные стороны, диагноз должен идти первым, а совместное изменение в логе — диагностика, бьющая бесконечный спор «что такое обязанность». Senior-ловушка — принять настоящую сквозную функциональность (логирование, авторизацию, трассировку) за стрельбу дробью и смять её в god-утилиту; им нужен тонкий стабильный шов (middleware, декоратор, контекст), а не централизация всего, что просто часто появляется.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.