Полиморфизм вместо условий
Когда один и тот же switch по типу встречается во многих местах, каждый новый вариант заставляет править всё. Перенеси поведение каждого случая на типы или в таблицу диспетчеризации, чтобы диспетчеризация стала одним механизмом, — но лишь когда switch повторяется.
Ты добавляешь новый способ оплаты — crypto — и компилятор тебе не помогает. Ты добавляешь case 'crypto' в калькулятор комиссий, выкатываешь, а через неделю финансы сообщают, что возвраты по крипте считаются неверно, в чеках по крипте написано «unknown method», а задача сверки крипту вообще пропускает. В трёх других файлах был тот же switch (method), а ты тронул только один. Сложной была не фича — сложным было найти каждое место, где переключаются по типу.
У этой картины есть имя: дробящая правка — одно концептуальное изменение, размазанное по множеству правок. Когда один и тот же switch по типу продублирован, каждый новый вариант — это охота, а пропущенное место — тихий баг. Лечение — сделать так, чтобы диспетчеризация происходила ровно в одном механизме.
После этого урока ты умеешь распознавать повторяющийся switch по типу как дробящую правку; выполнять рефакторинг «замена условия полиморфизмом» в TypeScript — либо через методы подтипов, либо через стратегию/таблицу диспетчеризации; и — это senior-часть — решать, когда этого не делать, потому что единственный маленький и не растущий switch яснее иерархии классов, а абстракция — это цена, которую платишь, только когда тип действительно варьируется во многих местах.
Запах — это один и тот же повторяющийся switch, а не switch сам по себе. Одиночный switch — это нормально. Проблема в том, что один и тот же набор случаев — та же лестница if (type === ...) — снова появляется в расчёте комиссии, рендеринге чека, обработке возврата и отчётности. Теперь знание «какие виды оплаты существуют и как каждый ведёт себя» размазано по N файлам.
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;
}
}
function label(p: Payment): string {
switch (p.method) { // <-- same axis of variation, second site
case "card": return "Card";
case "paypal": return "PayPal";
case "wire": return "Wire transfer";
}
}Добавить crypto значит править оба — и каждое другое место, где переключаются по p.method. Стоимость нового варианта растёт пропорционально числу мест, которые переключаются по типу.
Замена условия полиморфизмом: перенеси поведение каждого случая на тип. Дай каждому варианту объект, который знает собственные fee, label, refund. Тогда диспетчеризация происходит один раз, силами языка, когда ты вызываешь payment.fee() — switch по методу исчезает из каждого места вызова.
interface PaymentMethod {
fee(amount: number): number;
label(): string;
}
class Card implements PaymentMethod {
fee(amount: number) { return amount * 0.029 + 30; }
label() { return "Card"; }
}
class Wire implements PaymentMethod {
fee(_amount: number) { return 0; }
label() { return "Wire transfer"; }
}Теперь вызывающие fee и label просто пишут method.fee(amount) и method.label(). Добавить crypto — это один новый класс, единственная добавляющая правка, — а существующие места вызова не меняются. Это конкретное воплощение принципа открытости/закрытости: открыт для расширения (новый класс), закрыт для модификации (ни один существующий switch не тронут).
Таблица диспетчеризации — более лёгкая форма: тот же выигрыш, меньше церемоний. Класс на каждый вариант нужен не всегда. Когда поведение случая мало́ и похоже на данные, поиск по ключу-типу схлопывает те же N switch’ей в одну таблицу. В TypeScript это часто и есть нужная мера структуры.
const METHODS: Record<Payment["method"], {
fee: (amount: number) => number;
label: string;
}> = {
card: { fee: a => a * 0.029 + 30, label: "Card" },
paypal: { fee: a => a * 0.034 + 49, label: "PayPal" },
wire: { fee: () => 0, label: "Wire transfer" },
};
const feeOf = (p: Payment) => METHODS[p.method].fee(p.amount);
const labelOf = (p: Payment) => METHODS[p.method].label;Добавить crypto — это одна новая запись. А поскольку тип ключа — Payment["method"], TypeScript вынуждает таблицу быть исчерпывающей: пропусти метод — и оно не скомпилируется. Эта проверка компилятором — преимущество таблицы диспетчеризации над написанным руками switch без default.
Senior-компромисс: полиморфизм убирает повторяющиеся условия, но добавляет косвенность и разбрасывает логику одного метода по файлам. Решение — не «полиморфизм хорошо, switch плохо». Это баланс затрат и выгод, который можно почти что записать:
склоняйся к полиморфизму, когда (число мест, переключающихся по этому типу) × (темп прибытия новых вариантов) велико.
- Много мест, много вариантов → полиморфизируй. Новый вариант — это одна добавляющая единица; switch’и исчезают.
- Одно место, стабильный набор → оставь switch. Иерархия классов здесь не покупает ничего: ты обменял пятистрочный локальный
switchна файл, который надо открыть, чтобы понять одно поведение. Чтобы увидеть, какfeeработает по методам, теперь читаешь три файла вместо одного блока.
Режим отказа — это второй случай, сделанный неправильно: иерархия ради двухвариантного, стабильного различия. if (isAdmin) … else … не нуждается в полиморфном дереве AdminUser/RegularUser. Это избыточная абстракция — косвенность без давления расширения, которое бы её оправдало. Дорогая в откате ошибка — это обычно слишком много структуры ради различия, которое и не собиралось расти.
До — тот же switch в двух местах (и неявно ещё в нескольких).
type Payment = { method: "card" | "paypal" | "wire"; amount: number };
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;
}
}
function settlementDays(p: Payment): number {
switch (p.method) { // second site, same axis
case "card": return 2;
case "paypal": return 1;
case "wire": return 3;
}
}Добавить crypto значит найти и поправить оба switch’а — и любые другие. switch без default может предупредить, но лишь если ты вспомнил сделать возвращаемый тип исчерпывающим; ничто не помешает коллеге добавить case в одном файле и забыть про другой.
После — одна таблица диспетчеризации; диспетчеризация — единый механизм.
type Method = "card" | "paypal" | "wire";
const METHODS: Record<Method, { fee: (a: number) => number; settlementDays: number }> = {
card: { fee: a => a * 0.029 + 30, settlementDays: 2 },
paypal: { fee: a => a * 0.034 + 49, settlementDays: 1 },
wire: { fee: () => 0, settlementDays: 3 },
};
const fee = (p: Payment) => METHODS[p.method].fee(p.amount);
const settlementDays = (p: Payment) => METHODS[p.method].settlementDays;Теперь всё про метод живёт в одной строке. Добавить crypto — это единственная новая запись, а Record<Method, …> заставляет компилятор отвергнуть таблицу, забывшую метод, — та исчерпывающесть, которую разбросанные switch’и никогда не обеспечивали. Выигрыш был не в «объектах»; он был в том, чтобы схлопнуть множество точек диспетчеризации в одну.
▸Почему это работает
Почему это вообще ложится на принцип открытости/закрытости? OCP говорит, что модуль должен быть открыт для расширения, но закрыт для модификации. Повторяющийся switch — обратное: каждое расширение (новый вариант) требует модифицировать существующий код на каждом месте switch’а. Перенос диспетчеризации на тип — или в одну таблицу — делает «новый вариант» добавляющей операцией: ты добавляешь класс или строку, а закрытый код (места вызова) не тронут. В этом весь механизм, стоящий за OCP. Это не цель сама по себе; это свойство, которое достаётся даром, как только диспетчеризация живёт в одном месте, и закладываться на него стоит, только когда варианты действительно продолжают прибывать.
▸Частая ошибка
Перекоррекция хуже самого запаха. Увидев один switch, разработчик извлекает интерфейс PaymentMethod, три класса, фабрику и абстрактную базу — ради набора методов, который не менялся два года и переключается ровно в одном месте. Теперь прочитать «как вычисляется комиссия по карте?» значит открыть Card.ts, и там четыре файла вместо одного блока. Ты заплатил косвенностью авансом и купил ноль гибкости расширения, потому что тип не варьировался. Эвристика: не полиморфизируй switch, который целиком виден на одном экране и в который никто не добавляет случаев. Рефакторь в сторону полиморфизма, когда повторение и оборачиваемость вариантов реальны, — а не при первом виде условия.
В кодовой базе `switch (shape.kind)` для `area` повторяется в пяти файлах, и команда добавляет новую фигуру примерно каждый спринт. Второй `switch (user.role)` для одной проверки прав появляется ровно в одном месте и не менялся год. Какой из них стоит заменить полиморфизмом?
Switch по типу — запах только когда один и тот же switch повторяется: тогда каждый новый вариант становится дробящей правкой — N правок, а пропущенное место — тихий баг. Замени условие полиморфизмом, перенеся поведение каждого случая на тип (методы подтипов) или в таблицу диспетчеризации по ключу-типу, чтобы диспетчеризация стала одним механизмом, а новый вариант — единственной добавляющей правкой; это конкретный принцип открытости/закрытости. Но полиморфизм — это компромисс: он добавляет косвенность и разбрасывает логику одного метода по файлам. Выбирай по (сколько мест переключаются по этому типу) × (как часто появляются новые варианты). Режим отказа — иерархия классов ради двухвариантного, стабильного различия, переключаемого в одном месте, — избыточная абстракция, которая стоит чтения и не покупает гибкости. Полиморфизируй тот switch, что уже болит; оставь тот, что нет.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.