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

Выделение класса

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

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

Класс Person начинался как имя и email. Потом кому-то понадобился номер телефона, и добавили areaCode и number. Потом форматирование: phoneAsString(). Потом валидация: isValidAreaCode(). Класс по-прежнему компилируется, по-прежнему проходит свои тесты, по-прежнему уезжает в прод. Но теперь это две сущности под одним именем — человек и телефонный номер, который случайно живёт внутри человека.

Ты чувствуешь это, когда меняешь формат телефона и приходится прокручивать мимо логики имени и адреса, чтобы найти нужный метод, — или когда хочешь переиспользовать форматирование телефона для компании и не можешь, потому что оно приварено к Person. Класс работает. Просто он несёт внутри себя второй класс, и цена каждой правки телефона оплачивается радиусом поражения Person. «Выделение класса» — это то, как ты выпускаешь этот второй класс наружу.

Цель

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

1

Группа данных и кластер методов сами говорят, где шов, — ты его не выдумываешь, ты его читаешь. «Выделение класса» — это принцип единственной ответственности, применённый задним числом, и сигнал того, что две обязанности слиплись, — структурный, а не эстетический. Ищи группу данных: набор полей, которые всегда появляются вместе (areaCode + number или street + city + zip). Потом смотри, какие методы касаются только этого подмножества полей и игнорируют остальной класс. Это перекрытие — поля, которые кластеризуются, и методы, которые обслуживают только кластер, — и есть шов.

class Person {
  name: string;
  // ── the data clump: these three always move together ──
  areaCode: string;
  number: string;
  // ── the method cluster: it only ever touches the clump above ──
  phoneAsString(): string { return `(${this.areaCode}) ${this.number}`; }
}

Если у кандидата в «обязанности» есть данные, но нет методов, обслуживающих их исключительно, — или есть методы, но нет общих данных, — у тебя нет класса, готового выйти наружу: у тебя есть что-то меньшее (перемещаемый метод, переименованное поле). Шов должен проявиться в обоих измерениях, прежде чем выделение себя окупит.

2

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

class TelephoneNumber {} // empty on purpose

class Person {
  name: string;
  areaCode: string;
  number: string;
  private telephoneNumber = new TelephoneNumber(); // linked, unused — tests still green
  phoneAsString(): string { return `(${this.areaCode}) ${this.number}`; }
}

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

3

Перемещай поля по одному, заменяя каждое делегирующим аксессором, тесты зелёные после каждого перемещения. Возьми areaCode. Добавь его в TelephoneNumber с геттером/сеттером, затем сделай Person.areaCode делегирующим к this.telephoneNumber. Хранилище поля переехало; его публичная поверхность на Person не изменилась, поэтому вызывающие и тесты ничего не замечают. Повтори для number.

class TelephoneNumber {
  private _areaCode = "";
  get areaCode() { return this._areaCode; }
  set areaCode(v: string) { this._areaCode = v; }
}

class Person {
  // storage now lives in TelephoneNumber; Person just forwards
  get areaCode() { return this.telephoneNumber.areaCode; }
  set areaCode(v: string) { this.telephoneNumber.areaCode = v; }
  phoneAsString(): string { return `(${this.areaCode}) ${this.number}`; }
}

Причина делегировать, а не выдирать поле, — безопасность ссылок. Ты можешь не знать каждого, кто вызывает person.areaCode. Делегирующий аксессор оставляет их всех работающими, пока данные переезжают, так что ссылки можно переместить в более позднем, отдельном проходе, а не все сразу под давлением компилятора.

4

Перемести методы к данным, затем уведи вызывающих с пробросчиков Person и удали их. Теперь phoneAsString() читает только данные, живущие в TelephoneNumber, поэтому оно принадлежит ему. Перемести его. Person.phoneAsString() становится однострочным делегатом, а затем — как только вызывающие обновлены, чтобы спрашивать телефонный номер напрямую, — ты удаляешь делегат. Это и есть результирующее перемещение: поведение переехало к своим данным, в чём и весь смысл. Класс связен, когда его методы и данные, которые они используют, сидят вместе.

class TelephoneNumber {
  areaCode = "";
  number = "";
  toString(): string { return `(${this.areaCode}) ${this.number}`; }
}

class Person {
  name = "";
  telephoneNumber = new TelephoneNumber();
  // delegate kept only until callers move; then deleted
  phoneAsString(): string { return this.telephoneNumber.toString(); }
}

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

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

До — один класс, две обязанности. Order отрастил группу контактов клиента и окружающее её поведение, спутанное с логикой заказа:

class Order {
  items: LineItem[] = [];
  customerName = "";
  customerStreet = "";
  customerCity = "";
  customerZip = "";

  total(): Money { /* order concern */ }

  // a cohesive cluster that only touches the customer-address clump:
  shippingLabel(): string {
    return `${this.customerName}\n${this.customerStreet}\n${this.customerCity} ${this.customerZip}`;
  }
  isAddressComplete(): boolean {
    return !!(this.customerStreet && this.customerCity && this.customerZip);
  }
}

shippingLabel() и isAddressComplete() никогда не смотрят на items или total(). Это обязанность за адрес, прячущаяся внутри Order.

После — выдели Address, вместе с поведением и всем прочим. Поля переехали, и переехали методы, которые их обслуживают:

class Address {
  constructor(
    public name: string,
    public street: string,
    public city: string,
    public zip: string,
  ) {}
  label(): string { return `${this.name}\n${this.street}\n${this.city} ${this.zip}`; }
  isComplete(): boolean { return !!(this.street && this.city && this.zip); }
}

class Order {
  items: LineItem[] = [];
  constructor(public shipTo: Address) {}
  total(): Money { /* order concern, unchanged */ }
  // callers now ask the address directly: order.shipTo.label()
}

Пошагово это было так: ввести Address пустым и связать его; переместить четыре поля через делегирующие аксессоры (тесты зелёные каждый раз); переместить shippingLabel/isAddressComplete на Address как label/isComplete; перенаправить вызывающих на order.shipTo; удалить пробросчики. Address теперь переиспользуем (у склада тоже есть один), а у Order одна причина меняться. Заметь, что новый класс — не мешок данных: он несёт форматирование и валидацию, которые раньше были размазаны по Order.

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

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

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

Режим отказа: ты выделяешь класс, а выходит он чистым мешком данных — поля, геттеры, сеттеры и никакого поведения, — пока все методы, использующие эти поля, остались позади. Это анемичное выделение. Ты не разделил две обязанности; ты просто отделил данные одной обязанности от её логики, что повышает связанность (каждое изменение теперь охватывает два файла) и не покупает ничего. Две проверки, прежде чем отгружать выделение: (1) Поведение переехало вместе с данными? Если у нового класса нет методов, ты выделил не то. (2) А целый класс вообще был правильным инструментом? Если группа — это маленькая неизменяемая концепция (деньги, диапазон дат, координата), настоящее решение — объект-значение (равенство по значению, без идентичности), а не изменяемая сущность. И если был ровно один неуместный метод и никакой настоящей группы, решение было ещё меньше: просто Перемещение метода к данным, которым он завидует. «Выделение класса» — ответ только тогда, когда подлинный кластер данных и поведения просит собственного имени.

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

Ты выделяешь класс TelephoneNumber из Person. Когда закончил, TelephoneNumber хранит areaCode и number с геттерами/сеттерами и больше ничего, а phoneAsString() и валидация телефона по-прежнему живут на Person. Хорошее ли это выделение и почему?

Итог

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

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.