switch по типу и отвергнутое наследство
Два ОО-запаха. switch по типу, повторённый в N местах, растёт с каждым вариантом — сильнейший сигнал к замене условной логики полиморфизмом. Отвергнутое наследство, когда подкласс глушит унаследованные методы, значит, что «является» было ложным; лечится делегированием.
Ты добавляешь четвёртый канал уведомлений — Slack — и компилятор водит тебя по кодовой базе как экскурсовод: switch (type) в отправителе, ещё один в логике повторов, третий в навешивателе аналитических меток, четвёртый в форматтере. Четыре файла, та же форма, все по одной и той же строке. Фича была «один новый случай»; правка оказалась везде. Отдельно ты замечаешь ReadOnlyList extends ArrayList, у которого add() бросает исключение — класс, унаследовавший метод, который всё своё существование отвергает.
Оба — запахи с именами, и оба указывают на одну корневую ошибку: поведение, которое варьируется по типу, смоделировано не тем механизмом. switch по типу разбрасывает одно решение по всей кодовой базе; отвергнутое наследство заставляет потомка жить внутри родителя, в которого он на самом деле не вписывается. Этот урок — про то, как распознать каждый из них, и, что не менее важно, когда их не стоит «чинить».
После этого урока ты можешь распознавать повторяющийся switch по типу как сильнейший практический триггер к замене условной логики полиморфизмом и объяснять, почему это нарушение открытости/закрытости, а не придирка к стилю; опознавать отвергнутое наследство (подкласс, который глушит, бросает исключения на или выхолащивает унаследованные методы) как доказательство того, что отношение «является» ложно; тянуться к замене наследования делегированием как к лекарству; и формулировать режим отказа для обоих — превращать единственный стабильный switch в иерархию классов это переусложнение, а не исправление.
switch по типу становится запахом только когда повторяется; сигнал — это число дублирующихся switch, а не сам switch. Один switch по тегу, в одном месте, — это просто функция. Запах — это тот же switch — те же случаи, тот же дискриминант — появляющийся в трёх, четырёх, пяти местах. Теперь каждый новый вариант — это не одна правка, а N правок, и компилятор даже не предупредит тебя о тех, что ты забыл, если ветка default молча ничего не делает.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) { case "circle": return Math.PI * s.r ** 2; /* ... */ }
}
function perimeter(s: Shape): number {
switch (s.kind) { case "circle": return 2 * Math.PI * s.r; /* ... */ }
}
function describe(s: Shape): string {
switch (s.kind) { case "circle": return `circle r=${s.r}`; /* ... */ }
}Добавь triangle — и ты трогаешь area, perimeter, describe и каждую другую функцию с этим switch. Знание «какие виды существуют» продублировано по всем ним.
Повторяющийся switch по типу — это нарушение открытости/закрытости: добавление варианта вынуждает править существующий, работающий код. OCP говорит, что модуль должен быть открыт для расширения, но закрыт для модификации — ты должен мочь добавить triangle, не открывая заново area. Разбросанный switch делает это невозможным: новый случай приходится вклинивать в каждую функцию, которая диспетчеризует по kind. Замена условной логики полиморфизмом переворачивает это. Перенеси каждую ветку случая на тип, чтобы случаи одного switch стали методами одного объекта, а диспетчеризация — виртуальным вызовом языка.
interface Shape {
area(): number;
perimeter(): number;
describe(): string;
}
class Circle implements Shape {
constructor(private r: number) {}
area() { return Math.PI * this.r ** 2; }
perimeter() { return 2 * Math.PI * this.r; }
describe() { return `circle r=${this.r}`; }
}Добавить Triangle теперь — один новый класс, реализующий интерфейс — компилятор вынуждает тебя предоставить каждый метод, и ни один существующий класс не открывается заново. N switch свернулись в N методов в одном месте на тип.
Отвергнутое наследство: подкласс наследует методы, которые ему не нужны, и глушит их, бросает на них исключения или выхолащивает — доказательство того, что отношение «является» ложно. Наследование — это обещание: Penguin является Bird, поэтому везде, где код ожидает Bird, работает Penguin. Когда подклассу приходится переопределять унаследованный метод, чтобы он ничего не делал, бросал NotSupported или возвращал бессмысленное значение, он отвергает наследство — говорит тебе, что на самом деле он не та вещь.
class Bird {
fly(): void { /* move through the air */ }
layEgg(): Egg { /* ... */ }
}
class Penguin extends Bird {
fly(): void {
throw new Error("penguins can't fly"); // refused bequest
}
}Penguin унаследовал fly() лишь затем, чтобы от него отречься. Каждый вызывающий, который держит Bird и вызывает fly(), теперь — латентный краш для пингвинов, назревающее нарушение Лисков. Переопределение-через-исключение — это запах; ложный extends — это болезнь.
Лечи отвергнутое наследство заменой наследования делегированием: сохрани то, что действительно переиспользуешь, отбрось ложное «является». Если классу нужно какое-то поведение родителя, но не его тип, держи будущего родителя как поле и делегируй ему, выставляя только те методы, которые действительно применимы. Класс перестаёт быть Bird и начинает иметь нужное ему поведение откладывания яиц — композиция заменяет ложь фактом.
class Penguin {
private layer = new EggLaying(); // reuse without inheriting
layEgg(): Egg { return this.layer.layEgg(); }
swim(): void { /* what penguins actually do */ }
// no fly() exists to refuse — the interface is honest
}Теперь ни один вызывающий не может попросить Penguin fly(), потому что Penguin никогда не заявлял, что он Bird. Наследство не отвергнуто; оно никогда не было принято. Тянись к интерфейсу (EggLayer), если несколько типов разделяют контракт, и делегируй реализацию.
До — один switch по типу, скопированный по четырём файлам. Платёжный сервис диспетчеризует по method везде:
// charge.ts
function charge(p: Payment) {
switch (p.method) {
case "card": return chargeCard(p);
case "paypal": return chargePaypal(p);
case "wire": return chargeWire(p);
}
}
// fees.ts
function feeFor(p: Payment) {
switch (p.method) { case "card": return 0.029; case "paypal": return 0.034; case "wire": return 0; }
}
// settlement.ts, refunds.ts — тот же switch, ещё два разаДобавить crypto означает править charge, feeFor, settlement и refunds — четыре работающих файла открыты заново ради одного нового варианта, а пропущенная ветка default молча возвращает undefined.
После — замени условную логику полиморфизмом. Каждый метод становится стратегией, реализующей один интерфейс:
interface PaymentMethod {
charge(p: Payment): ChargeResult;
fee(): number;
}
class Card implements PaymentMethod {
charge(p: Payment) { /* ... */ }
fee() { return 0.029; }
}
class Crypto implements PaymentMethod { // новый вариант: ОДНО место
charge(p: Payment) { /* ... */ }
fee() { return 0.01; }
}
const methods: Record<string, PaymentMethod> = { card: new Card(), /* ... */ crypto: new Crypto() };charge, feeFor, settlement и refunds теперь вызывают methods[p.method].charge(...) / .fee(). Добавить Crypto — это единственный новый класс плюс запись в реестре; четыре места вызова никогда не меняются, а интерфейс вынуждает реализовать каждый метод — компилятор закрывает брешь, которую оставлял молчаливый default.
▸Почему это работает
Почему именно повторяющийся switch — а не единичный — сильнейший сигнал к полиморфизму? Потому что весь выигрыш полиморфизма — в сворачивании продублированной диспетчеризации в один механизм. switch, который встречается единожды, нечего сворачивать: иерархия классов, которую ты бы ввёл, — чистые накладные расходы, разбрасывающие одну читаемую функцию по нескольким файлам, между которыми теперь приходится прыгать, чтобы понять её. Когда тот же дискриминант управляет ветвлением во многих местах, каждый новый вариант облагает их все налогом, и именно этот налог полиморфизм снимает. Senior-прочтение механическое: посчитай switch по одному и тому же тегу. Один, локализованный, стабильный → оставь его. Три и больше или один, что наращивает по случаю за спринт → это и есть триггер.
▸Частая ошибка
Перекор: превращение единственного, локализованного, стабильного switch в иерархию классов, потому что «switch-операторы — это запах». switch из двух случаев в одной функции, не получивший нового случая за год, не враждебен к изменениям — замена его интерфейсом, двумя классами и фабрикой добавляет косвенность, файлы и стоимость чтения, покупая гибкость, которая ни одному входящему изменению не нужна. Это переусложнение под именем паттерна. Точно так же не тянись к делегированию, чтобы «очистить» отношение наследования, которое действительно есть «является» и чьи подклассы чтут каждый унаследованный метод — наследство не отвергнуто, значит, лечить нечего. Оба рефакторинга — ответы на улики (дублирующиеся switch; методы, что бросают исключения), а не на само наличие switch или extends.
Код-ревью показывает единственный switch по order.status с тремя случаями, в одной функции отчётности, без изменений больше года. Ревьюер помечает его: «switch-операторы — это запах, замени его полиморфизмом». Каково senior-решение?
Два объектно-ориентированных запаха разделяют один корень: поведение, которое варьируется по типу, смоделировано не тем механизмом. switch по типу становится запахом, когда повторяется — тот же дискриминант управляет ветвлением по многим местам — потому что каждый новый вариант тогда вынуждает править везде, а это нарушение открытости/закрытости; лекарство — замена условной логики полиморфизмом, сворачивающая N switch в методы на каждом типе, так что новый вариант — это один класс. Отвергнутое наследство — подкласс, который бросает исключения на, глушит или выхолащивает унаследованные методы — это улика того, что отношение «является» ложно; лекарство — замена наследования делегированием, держащая будущего родителя как поле и выставляющая только те методы, что честно применимы. Senior-дисциплина — действовать по уликам, а не по синтаксису: считай дублирующиеся switch, следи за переопределениями, что отрекаются от родителя, — и сопротивляйся превращению единственного стабильного switch в иерархию, которая меняет настоящую простоту на гибкость, о которой ни одно изменение не просит.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.