Выделение класса
Класс, который тихо отрастил вторую обязанность — группу данных плюс окружающее её поведение, — просится на разделение. «Выделение класса» — это SRP, сделанный рефакторингом: прочитай шов по группе данных, затем перемещай поля и методы пошагово под зелёными тестами.
Класс Person начинался как имя и email. Потом кому-то понадобился номер телефона, и добавили areaCode и number. Потом форматирование: phoneAsString(). Потом валидация: isValidAreaCode(). Класс по-прежнему компилируется, по-прежнему проходит свои тесты, по-прежнему уезжает в прод. Но теперь это две сущности под одним именем — человек и телефонный номер, который случайно живёт внутри человека.
Ты чувствуешь это, когда меняешь формат телефона и приходится прокручивать мимо логики имени и адреса, чтобы найти нужный метод, — или когда хочешь переиспользовать форматирование телефона для компании и не можешь, потому что оно приварено к Person. Класс работает. Просто он несёт внутри себя второй класс, и цена каждой правки телефона оплачивается радиусом поражения Person. «Выделение класса» — это то, как ты выпускаешь этот второй класс наружу.
После этого урока ты можешь распознать два сигнала того, что класс просится на разделение, — группу данных (поля, которые всегда ходят вместе) и связный кластер методов, касающийся только этих полей; выполнить «Выделение класса» как последовательность мелких, сохраняющих поведение перемещений под зелёными тестами (новый класс, перемещение полей, перемещение методов, обновление ссылок); и отличить настоящее выделение от анти-паттерна, в который оно чаще всего схлопывается, — безжизненного мешка данных, чьё поведение ты оставил позади.
Группа данных и кластер методов сами говорят, где шов, — ты его не выдумываешь, ты его читаешь. «Выделение класса» — это принцип единственной ответственности, применённый задним числом, и сигнал того, что две обязанности слиплись, — структурный, а не эстетический. Ищи группу данных: набор полей, которые всегда появляются вместе (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}`; }
}Если у кандидата в «обязанности» есть данные, но нет методов, обслуживающих их исключительно, — или есть методы, но нет общих данных, — у тебя нет класса, готового выйти наружу: у тебя есть что-то меньшее (перемещаемый метод, переименованное поле). Шов должен проявиться в обоих измерениях, прежде чем выделение себя окупит.
Создай новый класс пустым и свяжи его — первое перемещение не меняет ничего наблюдаемого. Начни с шага без эффекта: введи 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}`; }
}Дисциплина здесь в том, что «Выделение класса» — это не одна правка; это именованный рефакторинг, составленный из меньших именованных рефакторингов (Перемещение поля, Перемещение метода). Сделать его атомарно — вырезать всё, вставить в новый файл, исправить ошибки компилятора — это и есть то, как сохраняющее поведение перемещение превращается в случайное изменение поведения, которое ты обнаружишь в проде.
Перемещай поля по одному, заменяя каждое делегирующим аксессором, тесты зелёные после каждого перемещения. Возьми 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. Делегирующий аксессор оставляет их всех работающими, пока данные переезжают, так что ссылки можно переместить в более позднем, отдельном проходе, а не все сразу под давлением компилятора.
Перемести методы к данным, затем уведи вызывающих с пробросчиков 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.