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

Полиморфизм вместо условий

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

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

Ты добавляешь новый способ оплаты — crypto — и компилятор тебе не помогает. Ты добавляешь case 'crypto' в калькулятор комиссий, выкатываешь, а через неделю финансы сообщают, что возвраты по крипте считаются неверно, в чеках по крипте написано «unknown method», а задача сверки крипту вообще пропускает. В трёх других файлах был тот же switch (method), а ты тронул только один. Сложной была не фича — сложным было найти каждое место, где переключаются по типу.

У этой картины есть имя: дробящая правка — одно концептуальное изменение, размазанное по множеству правок. Когда один и тот же switch по типу продублирован, каждый новый вариант — это охота, а пропущенное место — тихий баг. Лечение — сделать так, чтобы диспетчеризация происходила ровно в одном механизме.

Цель

После этого урока ты умеешь распознавать повторяющийся switch по типу как дробящую правку; выполнять рефакторинг «замена условия полиморфизмом» в TypeScript — либо через методы подтипов, либо через стратегию/таблицу диспетчеризации; и — это senior-часть — решать, когда этого не делать, потому что единственный маленький и не растущий switch яснее иерархии классов, а абстракция — это цена, которую платишь, только когда тип действительно варьируется во многих местах.

1

Запах — это один и тот же повторяющийся 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. Стоимость нового варианта растёт пропорционально числу мест, которые переключаются по типу.

2

Замена условия полиморфизмом: перенеси поведение каждого случая на тип. Дай каждому варианту объект, который знает собственные 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 не тронут).

3

Таблица диспетчеризации — более лёгкая форма: тот же выигрыш, меньше церемоний. Класс на каждый вариант нужен не всегда. Когда поведение случая мало́ и похоже на данные, поиск по ключу-типу схлопывает те же 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.

4

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-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.