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

Дробить, не раскалывая

У SRP есть золотая середина. При перегибе он дробит один связный концепт в туман крошечных классов, которые приходится собирать в голове, — божественный класс наизнанку. Дроби по реальным границам изменений (независимый ритм, разные акторы); держи вместе то, что меняется вместе.

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

Ты научился разбивать божественный класс — тот OrderService на 2000 строк, который делал и ценообразование, и сохранение, и письма, и генерацию PDF. И ты разбил. И ещё разбил. И ещё. Теперь placeOrder трогает OrderValidator, OrderPriceCalculator, OrderTaxApplier, OrderTotalSummariser, OrderRepository, OrderEventEmitter и OrderMapper — восемь файлов, чтобы проследить один запрос, и каждый — тонкая обёртка, которая выполняет один оператор и делегирует дальше. Прочитать это никто не может. Чтобы понять единственный заказ, ты собираешь всю картину в голове, файл за файлом.

Ты обменял один провал на его зеркальное отражение. Божественный класс — это слишком мало дробления; этот туман осколков — слишком много. И то и другое — провалы SRP, и лекарство от одного не есть «больше того же лекарства». SRP оптимизирует связность, а у связности есть золотая середина. Этот урок — о том, как её найти.

Цель

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

1

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

// Классический пример Роберта Мартина: три актора прячутся в одном классе.
class Employee {
  calculatePay()  { /* меняется, когда меняется CFO / политика расчёта зарплаты */ }
  reportHours()   { /* меняется, когда меняется COO / операционная отчётность */ }
  save()          { /* меняется, когда меняется DBA / схема БД */ }
}

Эти три метода обслуживают трёх разных акторов, которые меняются по несвязанным причинам. Именно это — а не число строк — причина, почему Employee следует разделить. Правильное число частей — это число независимых причин для изменения, не больше и не меньше. Размер — это симптом, который ты исследуешь; он не само правило.

2

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

// Разные часы → дробим. PricingPolicy меняется по ритму финансов;
// ReceiptTemplate меняется по ритму маркетинга. Они никогда не движутся вместе.
class PricingPolicy   { totalFor(cart: Cart): Money { /* этим владеют финансы */ } }
class ReceiptTemplate { render(order: Order): Html  { /* этим владеет маркетинг */ } }

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

3

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

// Связно: и validate, и apply обеспечивают ОДИН инвариант
// (баланс никогда не уходит в минус) над ОДНИМ полем. Они всегда
// меняются вместе. Разбить их на классы Validator + Applier означало бы,
// что инвариант живёт в двух файлах, которые надо держать в синхроне.
class Account {
  #balance: Money;
  withdraw(amount: Money): void {
    if (amount.gt(this.#balance)) throw new InsufficientFunds();
    this.#balance = this.#balance.minus(amount); // то же поле, то же правило
  }
}

Когда ты дробишь это, изменение правила овердрафта теперь означает правку AccountValidator и AccountApplier и удержание их согласованными — ты изготовил ту самую дробовую хирургию, которой пытался избежать. Конкассентная логика (то, что должно меняться вместе, чтобы оставаться корректным) принадлежит вместе. Высокая связность и есть цель; SRP — лишь один рычаг на неё.

4

Взрыв числа классов — это провал SRP, а не успех SRP. Когда дробление порождает классы, у которых нет независимой причины для изменения, — Validator и Applier, которые всегда правятся в одном PR, Mapper, существующий лишь чтобы перенести три поля, — ты понизил связность, а не повысил. Цена реальна, и платят её при каждом чтении: косвенность без информации. Чтобы ответить на «что делает оформление заказа?», ты теперь открываешь восемь файлов и держишь их взаимодействие в рабочей памяти, потому что поведение раскололи между ними без границы, которая хоть что-то значит.

// Переприменённый SRP: одна связная операция атомизирована в делегирующие оболочки.
class OrderValidator    { validate(o: Order) { /* один if */ } }
class OrderPriceApplier { apply(o: Order)    { /* одна строка, зовёт Calculator */ } }
class OrderTotaller     { total(o: Order)    { /* одна сумма */ } }
// placeOrder() теперь оркеструет семь таких. Чтение его — это пересборка.

Senior-ход — распознать обе формы как один и тот же дефект — неверную связность, — направленный в противоположные стороны. Божественный класс — это много причин для изменения, запиханных в одну коробку; взрыв числа классов — одна причина для изменения, размазанная по многим коробкам. Ни то ни другое не отображает границу модуля на реальную границу изменений. SRP, сделанный хорошо, садится между ними: столько модулей, сколько требуют разные акторы, и не меньше.

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

Один генератор отчёта, дроблённый тремя способами — реален лишь один разрез. Начнём с класса, который строит отчёт о продажах:

class SalesReport {
  // (a) какие строки включать — меняется, когда финансы переопределяют «продажу»
  private select(orders: Order[]): Order[] { /* политика финансов */ }
  // (b) бегущие итоги — те же инварианты, те же поля, что и (a)
  private subtotal(rows: Order[]): Money { /* всегда правится вместе с (a) */ }
  // (c) рендеринг в HTML — меняется, когда дизайн обновляет шаблон
  private toHtml(rows: Order[], total: Money): Html { /* политика дизайна */ }
}

Раскалывающий разрез (неверный). Догматичное прочтение порождает пять классов: RowSelector, Subtotaller, TotalFormatter, HtmlRenderer, ReportOrchestrator. Но select и subtotal всегда меняются вместе — переопределение «продажи» меняет и то, какие строки идут в счёт, и то, как они суммируются. Они делят один финансовый инвариант. Разделить их — значит, что каждое изменение финансов теперь правка в два файла, удерживаемая в синхроне вручную. Ты создал дробовую хирургию и добавил оркестратор, которого никто не просил.

Сбалансированный разрез (верный). Акторов здесь ровно два, поэтому и модулей два:

// Актор «финансы»: что считается продажей и как она суммируется. Одна причина для изменения.
class SalesFigures {
  compute(orders: Order[]): { rows: Order[]; total: Money } {
    const rows = this.select(orders);
    return { rows, total: this.subtotal(rows) };
  }
  private select(orders: Order[]): Order[] { /* … */ }
  private subtotal(rows: Order[]): Money { /* … */ }
}

// Актор «дизайн»: как цифры представлены. Другая причина для изменения.
class SalesReportView {
  render(figures: { rows: Order[]; total: Money }): Html { /* … */ }
}

select и subtotal остаются вместе, потому что они — одна обязанность (финансовые цифры), сохраняя связность. Рендеринг отделяется, потому что отвечает другому актору с другим ритмом. Две реальные границы изменений — два модуля. Правка финансов трогает только SalesFigures; обновление шаблона трогает только SalesReportView; ни одно не тревожит другое, и никто не пересобирает пять осколков, чтобы понять один отчёт. Разрез следует за наблюдаемой реальностью, а не за сводом правил.

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

Почему «разные акторы / разный ритм» — тест лучше, чем «делает ли оно больше одной вещи»? Потому что всё делает больше одной вещи, если приблизить достаточно сильно — withdraw проверяет баланс и вычитает. Разложенный бесконечно, каждый метод — это «несколько обязанностей», и потому наивное прочтение упирается в взрыв числа классов. «Причина для изменения» обрывает этот регресс: ты перестаёшь дробить, когда оставшиеся части всегда менялись бы вместе, потому что нет будущего изменения, которому захотелось бы их разлучить. Граница задаётся изменением, которое конечно и наблюдаемо (читай историю коммитов), а не поведением, которое дробится без конца. Именно поэтому SRP — экономический принцип, а не эстетический: это снова стоимость изменения, теперь применённая к границам модулей.

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

Ловушка догматичного разреза: считать «один класс — один метод» или «один файл — один глагол» целью и механически рефакторить к ней. Признак — слой классов на -er: Validator, Mapper, Applier, Orchestrator, — у которых нет независимой причины для изменения и которые всегда появляются в коммитах вместе. Второй признак: чтобы проследить одну фичу, ты постоянно прыгаешь между файлами, каждый из которых выполняет один оператор и делегирует. Если ты не можешь ответить на вопрос «какое отдельное будущее изменение тронет этот модуль, но не его соседа?», разрез выдуман, и тебе следует слить осколки обратно в связный концепт, из которого их вырезали. Дробление не бесплатно; каждая добавленная граница — это косвенность, за пересечение которой будущий читатель платит. Добавляй границу только там, где реальная граница изменений уже существует.

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

Коллега разбил класс Account на AccountValidator (проверяет, что баланс не превышен) и AccountApplier (вычитает сумму). В истории git каждое изменение правила овердрафта правит оба файла вместе. Это хорошее дробление по SRP?

Итог

SRP оптимизирует связность, а у связности есть золотая середина с формой провала по каждую сторону: божественный класс запихивает много акторов в один модуль, тогда как взрыв числа классов размазывает одного актора по многим. И то и другое — один и тот же дефект (границы модулей, не совпадающие с реальными границами изменений), направленный в противоположные стороны, поэтому лекарство от одного никогда не сводится к «больше дробления». Надёжные тесты — про изменение, а не про размер: дроби, когда у двух обязанностей независимый ритм изменений или они отвечают разным акторам; держи вместе то, что всегда меняется вместе и делит состояние и инварианты. Читай историю коммитов — реальный шов проявляется как области, меняющиеся по разным причинам. Целься в столько модулей, сколько требуют разные акторы, и не меньше; защищай каждый разрез, называя то отдельное будущее изменение, которое он удешевляет, и сливай любой осколок, который так защитить не можешь.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.