Открыт для расширения, закрыт для модификации
OCP — это добавлять поведение, добавляя код (новый тип или стратегию), а не редактируя протестированный код. Но закрыться можно лишь против одной предсказанной оси вариативности; ошибёшься — переусложнишь. Навык — выбрать, какое изменение удешевить.
Каждая новая фигура, которую поддерживает твой рендерер, означает, что ты снова открываешь ту же функцию area() и добавляешь ещё один case в тот же switch. Функция компилируется, новая фигура работает — но ты только что отредактировал функцию, от которой уже зависели пять других фигур, и теперь каждую из них нужно перетестировать, потому что ты тронул их кодовый путь. Растущий switch — это тихий налог: каждое расширение оказывается модификацией.
Принцип открытости/закрытости — это дизайн, который убирает этот налог: добавляй новое поведение, добавляя файл, а не редактируя работающий. Подвох, который упускают джуны, в том, что закрыться против любого изменения нельзя — только против той одной оси вариативности, на которую ты сделал ставку. Этот урок — про то, как делать эту ставку, и про переусложнение, которое следует, когда ставишь неверно.
После этого урока ты можешь точно сформулировать OCP — открыт для расширения, закрыт для модификации вдоль выбранной оси; распознавать растущий switch/цепочку if как тот самый запах, против которого он направлен; рефакторить его в полиморфизм, чтобы новый вариант стал новым файлом, а существующий код остался нетронутым; и — senior-ход — решать, когда не применять OCP, потому что неправильная абстракция под вариацию, которая так и не наступит, — это её собственный дорогой режим отказа.
OCP: программные сущности должны быть открыты для расширения, но закрыты для модификации. «Закрыт для модификации» значит, что ты можешь выпускать новое поведение, не редактируя существующий, протестированный исходник. «Открыт для расширения» значит, что в дизайне есть шов, куда новое поведение подключается. Вместе они описывают код, который растёт через добавление. Канонический анти-паттерн — условие, переключающееся по тегу типа:
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.side ** 2;
}
}Добавь треугольник — и придётся редактировать area. Добавь периметр — и ты пишешь второй switch по тем же тегам. Этот код закрыт для расширения (без него фигуру не добавить) и открыт для модификации (его приходится постоянно редактировать) — ровно наоборот.
Решение — спрятать вариацию за интерфейс и дать каждому варианту владеть своим кодом. Инвертируй зависимость: вместо одной функции, знающей каждую фигуру, каждая фигура знает, как вычислить свою площадь. switch испаряется в систему типов.
interface Shape {
area(): number;
}
// circle.ts — целый новый файл
class Circle implements Shape {
constructor(private r: number) {}
area(): number { return Math.PI * this.r ** 2; }
}
// square.ts — ещё один файл
class Square implements Shape {
constructor(private side: number) {}
area(): number { return this.side ** 2; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0);
}Теперь добавить треугольник — это новый файл, triangle.ts, реализующий Shape. totalArea никогда не открывается. Ничто из того, что уже работало, не тронуто, поэтому ничего из того, что уже работало, не нужно перетестировать. В этом весь выигрыш: радиус поражения «новой фигуры» сжимается с «каждого потребителя» до «одного нового файла».
OCP — это цель вдоль ОДНОЙ оси, а не абсолют: ты выбираешь, какое изменение удешевить. Дизайн с фигурами выше закрыт против добавления фигуры и открыт против добавления операции: новая операция (периметр, ограничивающий прямоугольник) означает редактирование интерфейса Shape и каждого реализующего класса — ровно та боль, что была у switch, просто повёрнутая на 90°. Это проблема выражения: ты можешь сделать дешёвым либо новый-вариант, либо новую-операцию, редко то и другое бесплатно.
Поэтому OCP никогда не «закрыт, и точка». Это «закрыт против той оси, которая, как я предсказываю, будет варьироваться». Если фигуры добавляются часто, а операции редко — полиморфный дизайн выигрывает. Если ты в основном добавляешь операции к фиксированному набору фигур — switch (или visitor) — ставка лучше. Выбор оси и есть проектное решение; паттерн — лишь бухгалтерия после того, как ты выбрал.
Угадай ось неверно — и OCP становится переусложнением: его режим отказа — спекулятивная абстракция. Интерфейс, фабрика, реестр, загрузчик плагинов — каждый из них реальный код для чтения, и каждый — ставка на то, что определённая вариация наступит. Построй фреймворк PaymentProviderPlugin с динамическим обнаружением для продукта, у которого ровно один платёжный провайдер уже два года, — и абстракция не зарабатывает ничего: ты платишь стоимость косвенности при каждом чтении и никогда не получаешь гибкость.
// Спекулятивно: система плагинов под вариацию, которая ещё не появилась.
interface DiscountStrategy { apply(price: number): number; }
class DiscountRegistry { /* register, resolve, fallback, config... */ }
// ...500 строк фреймворка, а приложение отгружает ровно одну скидку: 10%.Это вотчина YAGNI. Дисциплинированное умолчание — написать конкретный код (price * 0.9) и рефакторить в OCP в момент, когда появится второй реальный вариант — когда ось изменения наблюдаема, а не воображаема. Две точки данных бьют одну догадку.
Отрефактори растущий диспетчер в закрытый-для-модификации. Отправщик уведомлений начинается невинно и обрастает switch, который каждый новый канал обязан снова открывать:
// До: каждый новый канал редактирует эту функцию и перетестирует каждую ветку.
function send(channel: string, msg: Message): void {
switch (channel) {
case "email": emailClient.send(msg.to, msg.body); break;
case "sms": smsGateway.text(msg.to, msg.body); break;
case "push": pushService.notify(msg.to, msg.body); break;
// …и "slack", "webhook", "whatsapp" продолжают приземляться сюда
default: throw new Error(`unknown channel: ${channel}`);
}
}Предсказанная ось вариативности — новые каналы (команда добавляет один почти каждый месяц). Именно её надо удешевить, поэтому вытолкни вариацию за интерфейс и дай каждому каналу быть своим файлом:
// channel.ts
export interface Channel {
readonly name: string;
send(msg: Message): Promise<void>;
}
// channels/email.ts — новый канал это новый файл, больше ничего не открывается
export class EmailChannel implements Channel {
readonly name = "email";
async send(msg: Message) { await emailClient.send(msg.to, msg.body); }
}
// sender.ts — закрыт: он никогда не меняется при добавлении канала
export class Sender {
constructor(private channels: Map<string, Channel>) {}
async send(channelName: string, msg: Message) {
const ch = this.channels.get(channelName);
if (!ch) throw new Error(`unknown channel: ${channelName}`);
await ch.send(msg);
}
}Добавить Slack — это теперь channels/slack.ts плюс одна строка регистрации: Sender и каждый существующий канал остаются нетронутыми и остаются зелёными. Но заметь, что мы не абстрагировали: форму Message, политику повторов, ограничение частоты. Они пока не варьируются, поэтому обернуть их в стратегии сейчас было бы спекулятивной ловушкой из Шага 4. Мы удешевили ровно одну ось, которую наблюдали варьирующейся, а остальное оставили конкретным.
▸Почему это работает
Почему удаление switch действительно снижает перетестирование, а не просто перекладывает его с места на место? Потому что условие связывает несвязанные варианты через общий изменяемый кодовый путь. Когда email, sms и push живут в одной функции, редактирование функции ради добавления slack может сломать любой из них — опечатка в общем default, переставленный break, переменная, теперь оказавшаяся в области видимости, — поэтому внимательный ревьюер обязан перепроверить их все. Разнеси по файлам — и код каждого канала физически изолирован: добавление SlackChannel не может, по построению, изменить байты EmailChannel. Поверхность тестов для «добавить канал» сжимается с «весь sender» до «новый файл», и это сжатие и есть выигрыш стоимости изменения, ради которого OCP существует.
▸Частая ошибка
Дорогое прочтение наоборот — «OCP значит никогда не использовать switch, всегда добавляй интерфейс». Это превращает контекстно-зависимую ставку в рефлекс, а рефлекс пере-абстрагирует. switch по закрытому, стабильному набору (HTTP-методы, дни недели, фиксированный набор из трёх тарифных уровней, не менявшийся за всю жизнь продукта) — это нормально: он уже закрыт против правильной оси, потому что эта ось не движется. Ввести Strategy на каждый case здесь — купить гибкость, которой ты никогда не воспользуешься, и разбросать логику, что была читаемой в одном месте. OCP оправдан наблюдаемой или уверенно предсказанной осью вариативности. Нет оси изменения — нет абстракции, switch остаётся.
У сервиса один платёжный провайдер, и так уже два года. Коллега предлагает интерфейс PaymentProvider с реестром плагинов, «чтобы мы были открыты для расширения». Через призму OCP + стоимости изменения — какое решение верное?
Принцип открытости/закрытости велит добавлять поведение, добавляя код — новый тип, стратегию или обработчик в своём собственном файле, — а не редактируя работающую, протестированную функцию вроде switch, который снова открывается под каждый case. Механизм — полиморфизм: вытолкни вариацию за интерфейс, чтобы каждый вариант владел своим кодом, а потребитель никогда не трогался, что сжимает поверхность перетеста «нового варианта» с каждого потребителя до одного нового файла. Но OCP — это цель вдоль одной оси, а не абсолют: закрыться можно лишь против той вариации, которую ты предсказываешь, а двойственная ось остаётся такой же дорогой, как раньше (проблема выражения). Senior-навык — выбрать, какое изменение удешевить, и его режим отказа — поставить неверно, построив фреймворк плагинов или иерархию стратегий под вариацию, которая так и не наступит. Поэтому умолчание — сначала конкретный код; рефакторить в OCP, когда второй реальный вариант докажет ось. Нет наблюдаемой оси изменения — нет абстракции.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.