Замена условного оператора полиморфизмом
Крупный ход под тестами: превратить разросшийся, повторяющийся switch по типу в полиморфное семейство по одному случаю за раз. Закрепи поведение тестами, введи интерфейс, мигрируй случаи инкрементально, удали switch последним — и только когда ось вариации реальна.
Ты знаешь желаемое конечное состояние: каждый способ оплаты — как отдельный объект, switch (method) исчез, новые способы добавляются написанием одного класса. Ты видел этот принцип. Но код перед тобой — это до: switch по пяти способам, продублированный в четырёх файлах, вшитый в прод, без единого теста, закрепляющего его поведение. Ты не видишь «после»; тебе нужно добраться туда, не сломав ни один из случаев, которые сейчас уезжают в бой.
Этот урок не про «полиморфизм бьёт условные операторы» — с этой идеей ты уже знаком. Он про механику самого хода: как senior рефакторит живой, непокрытый тестами switch по типу в полиморфное семейство маленькими обратимыми шагами, где сборка и тесты остаются зелёными всю дорогу. Большой переписыванием — это любительская версия. Профессиональная версия — последовательность скучных, безопасных коммитов.
После этого урока ты можешь выполнить «замену условного оператора полиморфизмом» как дисциплинированный рефакторинг, а не переписывание: сначала закрепить текущее поведение характеризующими тестами, ввести интерфейс и фабрику рядом с живым switch, мигрировать случаи по одному, держа сборку зелёной, и удалить исходный switch только когда его никто не читает — и ты можешь назвать запах, который санкционирует этот ход, и режим отказа, который должен тебя от него остановить.
Закрепи поведение, прежде чем трогать структуру — характеризующие тесты идут первыми. Нельзя безопасно преобразовать код, который ты не можешь наблюдать. Прежде чем вводить любой интерфейс, напиши тесты, фиксирующие, что switch делает сейчас для каждого случая, включая странные. Это не тесты правильного поведения; это тесты фактического поведения — сетка под трапецией.
// characterize the CURRENT fee() for every method — including the 'wire = 0' edge
describe("fee (current behaviour)", () => {
test.each([
["card", 1000, 59], // 1000 * 0.029 + 30
["paypal", 1000, 83], // 1000 * 0.034 + 49
["wire", 1000, 0],
["ach", 1000, 0], // legacy: ACH was silently free — pin it even if it's a bug
])("%s on %d → %d", (method, amount, expected) => {
expect(fee({ method, amount } as Payment)).toBe(expected);
});
});Если возврат 0 для ach на самом деле скрытый баг, ты всё равно его закрепляешь. Рефакторинг обязан сохранить поведение в точности; исправление бага — отдельный коммит со своим изменением теста. Смешивание этих двух — это то, как «всего лишь рефакторинг» отгружает регрессию.
Введи интерфейс и фабрику рядом с живым switch — пока ничего не заменяй (параллельное изменение). Добавь новую структуру рядом со старым кодом, чтобы оба существовали одновременно. Новые классы пока никто не вызывает; switch по-прежнему работает в проде. Это фаза расширения параллельного изменения: ты строишь пункт назначения, прежде чем перенаправить в него трафик.
interface PaymentMethod {
fee(amount: number): number;
}
class Card implements PaymentMethod {
fee(amount: number) { return amount * 0.029 + 30; }
}
// factory: the ONE place that still knows the string→type mapping
function methodFor(p: Payment): PaymentMethod {
switch (p.method) {
case "card": return new Card();
// others fall through to the old path for now
default: return new LegacyAdapter(p);
}
}LegacyAdapter делегирует обратно к исходному switch для любого ещё не мигрированного случая. Теперь система работает на новом пути диспетчеризации для card и на старом пути для всего остального — а твои характеризующие тесты по-прежнему проходят, потому что поведение идентично. Здесь коммить.
Мигрируй по одному случаю за раз; прогоняй тесты после каждого. Это сердце хода. Для каждого варианта: создай его класс, скопируй тело case в метод, нацель фабрику на него, удали этот один case из исходного switch, прогони набор. Один случай, одна зелёная полоса, один коммит. Если шаг покраснел, ты изолировал поломку до одного маленького изменения, которое можешь откатить за секунды.
class Wire implements PaymentMethod {
fee(_amount: number) { return 0; } // copied verbatim from `case "wire"`
}
// factory gains: case "wire": return new Wire();
// original switch loses: case "wire": return 0;
// → run tests → green → commitСопротивляйся желанию мигрировать все пять разом «чтобы сэкономить коммиты». Вся ценность техники в том, что ошибка ограничена одним случаем и обратима. «Все разом» превращает безопасную последовательность в рискованное переписывание, которое лишь случайно размазано по меньшему числу нажатий клавиш.
Удали switch последним — только когда его никто не читает. Когда каждый случай переехал на класс, а ветка default/LegacyAdapter фабрики недостижима, исходный switch и адаптер — мёртвый код. Теперь удали их. Фаза сжатия: старый путь исчез, новый путь — единственный путь, и тесты, которые проходили на каждом шаге, по-прежнему проходят.
function methodFor(p: Payment): PaymentMethod {
switch (p.method) { // now the ONLY switch left — a pure factory
case "card": return new Card();
case "paypal": return new PayPal();
case "wire": return new Wire();
case "ach": return new Ach();
}
}
// every OTHER switch on p.method across the codebase is now goneЗаметь, что один switch выживает — фабрика. Это правильно и сделано намеренно: ты схлопнул N разбросанных switch по типу в единственную точку диспетчеризации. Добавить crypto теперь — это один класс плюс одна строка в фабрике, а не охота по четырём файлам. Если ты ещё хочешь, чтобы компилятор проверял исчерпывающесть, таблица диспетчеризации Record<Payment["method"], …> заменяет даже switch фабрики.
До — живой, повторяющийся switch, который тебе достался:
// fees.ts
function fee(p: Payment): number {
switch (p.method) {
case "card": return p.amount * 0.029 + 30;
case "paypal": return p.amount * 0.034 + 49;
case "wire": return 0;
}
}
// label.ts, refund.ts, report.ts — the SAME switch (p.method), three more timesХод, как коммиты (каждый отгружается зелёным):
- Закрепить. Характеризующие тесты для
fee,label,refundпоcard/paypal/wire. Они проходят против текущего кода. - Расширить. Добавь
interface PaymentMethod { fee(a): number; label(): string; refund(a): number }, классCardиmethodFor(p), возвращающийLegacyAdapterдля немигрированных случаев. Поведение прода не изменилось. Коммить. - Мигрировать
card. Заполни три методаCardиз casecardтрёх switch; нацельmethodForнаCard; удалиcase "card"из всех трёх switch. Тесты зелёные. Коммить. - Мигрировать
wire, затемpaypal— тот же рецепт, по одному на коммит. - Сжать. Все три исходных switch теперь пусты; удали их и
LegacyAdapter. Единственный оставшийся switch — фабрика вmethodFor. Тесты зелёные. Коммить.
После:
class Card implements PaymentMethod {
fee(a: number) { return a * 0.029 + 30; }
label() { return "Card"; }
refund(a: number) { return a; }
}
const total = methodFor(p).fee(p.amount); // call sites no longer switchДифф большой, но он приземлился как шесть маленьких, по отдельности откатываемых шагов, каждый из которых был зелёным. Именно это делает его рефакторингом, а не переписыванием-с-молитвой.
▸Почему это работает
Почему LegacyAdapter и параллельный путь, а не просто переписать fee целиком? Потому что это позволяет проду работать на смеси старой и новой диспетчеризации, пока ты мигрируешь, так что у тебя никогда нет окна, в котором часть случаев сломана. Форма расширение/миграция/сжатие («параллельное изменение») держит сборку зелёной и развёртываемой на каждом коммите — что огромно важно, когда switch вшит во что-то, что нельзя вывести из эксплуатации. Цена — несколько одноразовых строк (адаптер), которые ты удаляешь в конце; выигрыш в том, что запоротая миграция случая — это однострочный откат, а не откаченный релиз.
▸Частая ошибка
Режим отказа — устроить всю церемонию ради различия, которое её не оправдывает. Стабильному if (status === "active") … else … с двумя ветками не нужна иерархия ActiveAccount/ClosedAccount — маленький switch или таблица поиска понятнее и дешевле в чтении. Триггер для этого рефакторинга — реальный, наблюдаемый запах: тот же switch по типу, продублированный по нескольким файлам, с реально прибывающими новыми вариантами. В отсутствие этого запаха выполнение хода покупает тебе косвенность и разбросанную логику в обмен на гибкость, которой ты никогда не воспользуешься — это переабстракция, за которую расплачивается следующий читатель. Ход — это ответ на запах, а никогда не значение по умолчанию. Прежде чем начать, спроси: ось вариации реальна и растёт, или я просто паттерн-матчу на «switch = плохо»?
Ты заменяешь повторяющийся switch по типу из пяти случаев полиморфным семейством в сервисе, который живёт в проде и не имеет тестов. Какой правильный ПЕРВЫЙ шаг?
«Замена условного оператора полиморфизмом» как крупный ход — это хореография, а не переписывание. Закрепи текущее поведение характеризующими тестами, расширь, подняв интерфейс и фабрику рядом с живым switch, мигрируй случаи по одному, чтобы сборка и тесты оставались зелёными после каждого шага, затем сожми, удалив исходный switch, как только его никто не читает — схлопывая N разбросанных switch по типу в одну точку диспетчеризации, где новый вариант — это один класс. Это принцип открытости/закрытости, достигнутый преобразованием, под сеткой, обратимыми коммитами. Но ход санкционируется запахом — тот же switch, повторённый по файлам, с всё ещё прибывающими вариантами — а не рефлексом. Устройство всей церемонии ради стабильного различия из двух ветвей меняет понятный локальный switch на косвенность, которая никому не была нужна. Рефактори в сторону полиморфизма, когда ось вариации реальна и растёт; иначе оставь маленький switch в покое.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.