Правило трёх
Не абстрагируй на первом или втором появлении — дождись третьего, когда реальная ось вариации наконец видна. Ранняя абстракция угадывает ось неверно и окостеневает в неправильной форме. Исключение — очевидное дублирование знания: его убирают сразу.
Ты пишешь функцию. Через неделю ты пишешь вторую, подозрительно на неё похожую. Срабатывает рефлекс DRY: вынеси это, переиспользуй, никогда не повторяйся. И ты поднимаешь хелпер shared() с флагом mode, чтобы замазать единственное место, где эти две различаются, — и чувствуешь себя праведником.
Потом приходит третий вызывающий, и он различается по совершенно другой оси, чем та, что захватил твой флаг. Теперь твой «общий» хелпер отращивает второй флаг, потом ветку, потом колбэк — пока не превращается в узел, с которым воюет каждый вызывающий и в который не вписывается ни один. Два примера не показали тебе паттерн. Они показали какой-то паттерн — и ты угадал неверно.
После этого урока ты можешь сформулировать правило трёх и объяснить, почему это сознательное смещение против спекулятивной абстракции, а не лень; отличить случайное сходство (две вещи, которые просто похожи) от реального общего концепта; осознать, что неправильная абстракция дороже дублирования, которое она заменила; и назвать единственный случай, когда правило не применяется, — очевидное, заведомо известное дублирование знания, которое нужно устранять с первого взгляда.
Два появления не раскрывают ось вариации — три раскрывают. Абстракция — это утверждение: «эти случаи одинаковы кроме как по одной оси». При одном появлении у тебя нет оси вообще. При двух ты видишь какое-то различие, но не можешь сказать, то ли это различие — измерение, по которому будущие случаи реально будут варьироваться, — или просто случайность именно этих двух.
// Появление #1
function priceForBook(b: Book) {
return b.basePrice * 1.2; // VAT
}
// Появление #2 — выглядит как та же форма с другой ставкой
function priceForEbook(e: Ebook) {
return e.basePrice * 1.07; // reduced VAT
}Соблазнительно вынести priceWith(rate). Но ставка ли реальная ось? Или следующий случай будет варьироваться по региону, по типу клиента, по промо-переопределению? Две точки задают прямую, и прямая через две точки всегда ложится идеально — именно поэтому она ничего не говорит о том, где окажется третья точка.
Преждевременная абстракция угадывает ось неверно, а потом окостеневает в неправильной форме. Опасность не в напрасно потраченных усилиях на ранний вынос — она в том, что ранняя абстракция связывает тебя обязательством. Как только три вызывающих зависят от priceWith(rate), эта сигнатура становится несущей. Когда реальная вариация оказывается региональными налоговыми таблицами, ты не можешь просто поменять абстракцию — тебе приходится с ней воевать: добавить второй параметр, протянуть флаг, сделать частный случай для одного вызывающего, и хелпер обрастает сложностью, чтобы покрыть форму, под которую он никогда не проектировался.
// Во что вырождается «выноси на втором» к пятому вызывающему:
function price(base: number, rate: number, region?: Region,
isExempt?: boolean, override?: number) {
if (override != null) return override;
if (isExempt) return base;
const r = region ? rateFor(region) : rate; // исходный `rate` теперь рудимент
return base * r;
}Каждый флаг — это рубец от неверной догадки. Абстракция теперь скрывает больше, чем разделяет.
Дублирование дешевле неправильной абстракции — пока паттерн не прояснился. Это senior-инверсия юниорского инстинкта. Юниорское рассуждение: «дублирование всегда плохо, устрани его сейчас». Senior-рассуждение: у дублирования известная, ограниченная стоимость — N копий, каждую можно править на месте, каждая независимо корректна. У неправильной абстракции неизвестная, растущая стоимость — все вызывающие теперь связаны через форму, которая не подходит, и распутать её труднее, чем если бы они оставались порознь, потому что сначала нужно обратно встроить абстракцию, прежде чем удастся разделить их правильно.
Поэтому правило трёх — это ставка на асимметрию стоимости: заплатить маленькую, видимую цену дублирования за ещё одно появление — значит купить информацию, чтобы абстрагировать правильно, а правильная абстракция — единственная, ради которой стоит её косвенность. Подождать — более дешёвый ход именно потому, что ты пока недостаточно знаешь, чтобы быть правым.
Исключение: очевидное дублирование знания — это не «ожидание третьего примера». Правило трёх управляет структурным сходством — кодом, который случайно выглядит похоже, но чей общий концепт ещё не доказан. Оно не даёт права копипастить факт, про который ты уже знаешь, что у него один источник. Налоговая ставка, регуляторный порог, магический путь API, бизнес-правило с одним истинным значением — это дублирование знания, и третья, четвёртая и двенадцатая копия не раскрывают ничего нового. Ты уже знаешь, что это один концепт.
// НЕ кандидат для правила трёх — это один факт, скопированный:
const fee = subtotal * 0.029 + 30; // billing.ts
const fee = subtotal * 0.029 + 30; // refund.ts
const fee = subtotal * 0.029 + 30; // report.ts ← 0.029 в 12 файлах — баг в ожидании дрейфаПрятаться за «правилом трёх», чтобы не назвать STRIPE_FEE, — это злоупотребление. Правило откладывает абстракцию, когда паттерн неясен; оно никогда не откладывает устранение дубликатов значения, которое ты уже можешь назвать. Тест: можешь ли ты дать общей вещи истинное, стабильное имя прямо сейчас? Если да — это знание, выноси его. Если единственное честное имя — doTheSimilarThing, ось ещё не ясна — подожди.
Пусть третий вызывающий назовёт ось. Два экспортёра отчётов, намеренно оставленные дублированными:
// csvExport.ts
function exportUsers(users: User[]): string {
const header = "id,name,email";
const rows = users.map(u => `${u.id},${u.name},${u.email}`);
return [header, ...rows].join("\n");
}
// csvExportOrders.ts
function exportOrders(orders: Order[]): string {
const header = "id,total,status";
const rows = orders.map(o => `${o.id},${o.total},${o.status}`);
return [header, ...rows].join("\n");
}При двух появлениях то, что варьируется, выглядит как «колонки». Если вынести сейчас, ты построишь toCsv(rows, columns) с ключом по колонкам. Потом приходит третий вызывающий:
// xmlExportInvoices.ts ← реальной осью был ФОРМАТ, а не колонки
function exportInvoices(invoices: Invoice[]): string {
return `<invoices>${invoices.map(i =>
`<invoice id="${i.id}" total="${i.total}"/>`).join("")}</invoices>`;
}Теперь истинная ось видна: формат сериализации, а не набор колонок. Хелпер toCsv(columns), вынесенный на втором появлении, воевал бы с этим — счета-фактуры вообще не производят строк-и-колонок. Правильная абстракция, которую раскрывает третье появление, — это маленький шов Serializer:
interface Serializer<T> { (records: T[]): string; }
const csv = <T>(cols: (keyof T)[]): Serializer<T> => recs =>
[cols.join(","), ...recs.map(r => cols.map(c => r[c]).join(","))].join("\n");
const xml = <T>(tag: string): Serializer<T> => recs =>
`<${tag}s>${recs.map(/* ... */).join("")}</${tag}s>`;Дублирование стоило нам двух почти одинаковых функций за одно лишнее появление. В обмен мы абстрагировали вдоль оси, которая реальна на самом деле, вместо того чтобы окостенеть в «колонках» — форме, которую третий случай сломал бы.
▸Почему это работает
Почему три, а не два или четыре? Это эвристика, а не теорема — число кодирует компромисс по стоимости. Одно появление: нечего абстрагировать. Два: ты видишь различие, но не можешь отличить реальную ось от совпадения, а двухточечная подгонка всегда идеальна, поэтому она максимально вводит в заблуждение. Три — наименьшая выборка, где проступает направление: измерение, по которому случаи действительно варьируются, начинает отделяться от случайных различий. Это точка, где стоимость продолжения дублирования начинает явно превышать (теперь намного меньший) риск угадать абстракцию неверно. Относись к трём как к «достаточному сигналу», а не к магической константе; сильный, очевидный паттерн может оправдать два, а мутный — потребовать ждать четыре.
▸Частая ошибка
Два режима провала симметричны, и senior-инженеры промахиваются в обе стороны. Слишком ретиво: трактовать правило как «DRY, немедленно» — выносить на втором появлении и выкатывать хелпер, утыканный флагами, с которым воюет каждый последующий вызывающий. Слишком терпеливо: превращать правило в оружие, чтобы увильнуть от работы, про которую ты знаешь, что это один концепт, — оставлять 0.029 (или процедуру парсинга, или проверку авторизации) скопированным по всей кодовой базе и махать «правилом трёх» перед ревьюером. Первый абстрагирует неопределённость; второй отказывается абстрагировать определённость. Различитель — это имя: если ты можешь дать общей вещи истинное, стабильное имя сегодня — это знание, устраняй дубликат сейчас. Если самое честное имя, которое ты можешь дать, — «похоже выглядящая штука», ось не ясна — жди третьего примера, чтобы он сказал тебе, что это на самом деле.
Ты видишь литеральную формулу платёжной комиссии <code>amount * 0.029 + 30</code>, скопипащенную во второй файл. Коллега говорит: «правило трёх — оставь, пока не будет третьей копии». Какой правильный senior-ответ?
Правило трёх — это сознательное смещение против спекулятивной абстракции: не выноси на первом или втором появлении, потому что два случая не могут раскрыть истинную ось вариации — они ложатся на любую прямую, которую через них проведёшь. Ранняя абстракция угадывает эту ось неверно, а потом окостеневает в неправильной форме, обрастая флагами и ветками, пока поздние вызывающие воюют со швом, который под них никогда не проектировался. Senior-инверсия в том, что дублирование дешевле неправильной абстракции, пока паттерн не прояснился: стоимость дублирования ограничена и локальна, а стоимость неправильной абстракции неизвестна и связывает. Единственное жёсткое исключение — дублирование знания: один известный факт вроде ставки комиссии или бизнес-правила, скопированный по файлам. Это не ожидание третьего примера; это баг дрейфа. Различитель — можешь ли ты дать общей вещи истинное, стабильное имя сегодня: если да — устраняй дубликат сейчас; если единственное честное имя — «похожая штука», жди третьего появления, чтобы оно сказало тебе, что это на самом деле.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.