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

Неправильная абстракция

Дублирование дешевле неправильной абстракции. Преждевременный общий метод обрастает флагом на каждого вызывающего, пока не превратится в клубок. Лечение — встроить его обратно, а затем заново вывести верную абстракцию из ставших видимыми различий.

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

Ты открываешь функцию, от которой зависят шесть вызывающих. Она принимает восемь параметров, четыре из них — булевы: isPreview, skipValidation, forEmail, legacyMode, — а тело представляет собой заросли if (forEmail) … else if (legacyMode) …. Каждую ветку добавлял свой человек, решая свою задачу, и все они слиты в один метод, потому что когда-то две из них выглядели похоже. Теперь никто не может её изменить, не прочитав все шесть мест вызова и не помолившись.

Это неправильная абстракция, и она — одна из самых дорогих форм, которые может принять код, дороже того дублирования, которое она была призвана вылечить. Senior-ход здесь контринтуитивен и немного пугает: ты собираешься намеренно сделать код более дублированным, встроив абстракцию обратно в её вызывающих, чтобы настоящая абстракция наконец смогла себя показать.

Цель

После этого урока ты можешь распознавать жизненный цикл неправильной абстракции (преждевременное слияние → обрастание флагами → нечитаемый клубок); объяснять утверждение Сэнди Метц о том, что «дублирование намного дешевле неправильной абстракции», в терминах стоимости изменения; выполнять ход «расабстрагирования» на TypeScript — встроить обратно в каждого вызывающего, а затем заново вывести подлинную общую концепцию из ставших видимыми различий; и сопротивляться рефлексу невозвратных затрат, который держит явно неправильную абстракцию в живых, потому что её удаление «ощущается как нарушение DRY».

1

Неправильная абстракция рождается, когда ты сливаешь две вещи, которые были похожи лишь случайно. У двух функций были похожие тела, поэтому кто-то вынес общий метод. Но похожий код — не то же самое, что общий смысл. Двум вызывающим сегодня случайно понадобились одни и те же шаги; они никогда не были одной концепцией. Вынос связывает два требования, у которых нет причин меняться вместе, — и теперь они вынуждены.

// Day 1: these looked identical, so they got merged.
function renderItem(item: Item) {
  return `<li>${item.name}: ${item.price}</li>`;
}
// "invoiceLine looks the same!" → extract a shared formatter.

Ошибка — принимать структурное сходство за свидетельство общей абстракции. Это не так. Единственное настоящее свидетельство — что две вещи меняются вместе по одной и той же причине, — а этого ты обычно не можешь знать в первый день.

2

Затем абстракция гниёт от обрастания: булев (или enum) параметр на каждого нового вызывающего. Третьему вызывающему нужно почти то же самое — но с поправкой. Путь наименьшего сопротивления — добавить параметр и if. Потом четвёртый. Каждое добавление локально разумно и глобально катастрофично: ветки функции множатся, её смысл размывается, а сигнатура превращается в чек-лист несвязанных флагов.

// Each new requirement bolts on a flag instead of forking.
function render(
  item: Item,
  forEmail: boolean,
  isPreview: boolean,
  legacyMode: boolean,
  skipPrice: boolean,
) {
  if (legacyMode) return renderLegacy(item);
  const price = skipPrice ? "" : `: ${item.price}`;
  if (forEmail) return `<tr><td>${item.name}${price}</td></tr>`;
  if (isPreview) return `<li class="preview">${item.name}${price}</li>`;
  return `<li>${item.name}${price}</li>`;
}

Булев параметр, выбирающий между двумя поведениями, — надёжный сигнал: функция делает две работы. Четыре таких параметра — четыре работы под одним именем. Флаги — это годовые кольца неправильной абстракции.

3

«Дублирование намного дешевле неправильной абстракции». DRY служит изменяемости — а не наоборот. Правило Сэнди Метц — эвристика, разрешающая центральное напряжение этого юнита. У дублирования известная, ограниченная стоимость: если правило меняется, ты правишь его в N местах, и худший случай — что одно пропустишь. У неправильной абстракции неограниченная, нарастающая стоимость: каждый вызывающий теперь связан с краевыми случаями каждого другого вызывающего, каждое изменение рискует всеми шестью местами вызова, а клубок становится хуже с каждым «маленьким» добавлением.

DRY — не самоцель. Это средство для настоящей цели — дешёвого изменения. Когда «DRY»-абстракция делает изменение дороже, чем было бы дублирование, DRY применён против собственного предназначения. Senior-прочтение DRY таково: убирай дублирование знания, которое меняется вместе. Код, который лишь выглядит одинаково, но меняется по разным причинам, — не то дублирование, о котором говорит DRY.

4

Лечение — встроить абстракцию обратно в каждого вызывающего (намеренно продублировать заново), а затем заново вывести настоящую абстракцию из различий, которые ты теперь видишь. Ты не можешь найти правильный шов, пока неправильный прячет данные. Поэтому ты обращаешь вынос вспять: копируешь нужную ветку общей функции в каждого вызывающего, удаляешь флаг, который её выбирал, и даёшь каждому месту вызова снова стать маленькой, честной, самостоятельной функцией.

// Step A — inline back. Each caller gets just its own branch.
function renderListItem(item: Item) {
  return `<li>${item.name}: ${item.price}</li>`;
}
function renderEmailRow(item: Item) {
  return `<tr><td>${item.name}: ${item.price}</td></tr>`;
}
function renderPreview(item: Item) {
  return `<li class="preview">${item.name}: ${item.price}</li>`;
}

Теперь дублирование видимо и локально. Когда все три рядом, проявляется подлинная общая концепция: все они форматируют ${name}: ${price} и оборачивают это по-разному. Вот это — внутренняя строка «имя + цена» — и есть настоящая абстракция; обёртка же законно варьируется.

// Step B — re-derive the TRUE shared concept (the inner content, not the wrapper).
function nameAndPrice(item: Item) {
  return `${item.name}: ${item.price}`;
}
const renderListItem  = (i: Item) => `<li>${nameAndPrice(i)}</li>`;
const renderEmailRow  = (i: Item) => `<tr><td>${nameAndPrice(i)}</td></tr>`;
const renderPreview   = (i: Item) => `<li class="preview">${nameAndPrice(i)}</li>`;

Новая абстракция мала, без флагов, и каждый вызывающий читается сам по себе. Ты не «сдался на DRY» — ты нашёл дублирование, которое действительно разделяет смысл, и вынес только его.

5

Будь готов удалить абстракцию. Невозвратные затраты — не причина держать неправильную, а «удалить её ощущается как нарушение DRY» — тот режим отказа, который нужно назвать и побороть. Цена, уже уплаченная за создание плохой абстракции, потеряна, оставишь ты её или нет; единственный вопрос — стоимость отсюда и далее. Если абстракция делает каждое будущее изменение дороже, держать её из верности прошлым усилиям значит платить эту цену вечно.

Конкретная ловушка завязана на идентичности: инженеры усваивают «DRY = хорошо», поэтому встраивание включает рефлекс «это ощущается неправильно» даже там, где оно очевидно верно. Заметь это чувство и переосмысли: ты не отказываешься от DRY, ты служишь той цели, ради которой DRY существует. Когда НЕ встраивать: если абстракция действительно правильна (вызывающие реально меняются вместе по одной причине) и лишь неуклюжа, чини её на месте — не сноси несущий шов из-за того, что у него плохое имя. Навык — отличать случайное слияние от настоящего; признак — меняются ли требования вызывающих вместе.

Разбор примера

Расабстрагируем построитель уведомлений, управляемый флагами. Три фичи — подтверждение заказа, сброс пароля и маркетинговая рассылка — были слиты в один buildNotification, потому что их ранние версии пересекались.

// BEFORE — the wrong abstraction, three jobs under one name.
function buildNotification(
  user: User,
  kind: "order" | "reset" | "promo",
  opts: { includeUnsubscribe?: boolean; trackOpens?: boolean } = {},
) {
  const greeting = `Hi ${user.firstName},`;
  let body: string;
  if (kind === "order")  body = `Your order ${user.lastOrderId} is confirmed.`;
  else if (kind === "reset") body = `Reset your password: ${resetLink(user)}`;
  else body = promoCopy(user.segment);

  let footer = "";
  if (opts.includeUnsubscribe) footer += unsubscribeFooter(user);
  if (kind === "reset") footer += " This link expires in 1 hour.";   // leaked into the shared path
  const pixel = opts.trackOpens ? trackingPixel(user) : "";          // only promo wants this
  return `${greeting}\n${body}\n${footer}${pixel}`;
}

Запах: kind плюс два опциональных флага выбирают между тремя несвязанными сценариями, а частные случаи (срок жизни для reset, пиксель для promo) протекли в общее тело. Изменение в трекинге промо вынуждает тебя рассуждать о письмах сброса пароля. Теперь встроим обратно в каждого вызывающего, а затем оставим лишь то, что действительно повторяется:

// AFTER — inline to honest functions; extract only the real shared concept.
const greeting = (u: User) => `Hi ${u.firstName},`;   // ← the one thing that genuinely recurs

function buildOrderEmail(user: User) {
  return `${greeting(user)}\nYour order ${user.lastOrderId} is confirmed.`;
}
function buildResetEmail(user: User) {
  return `${greeting(user)}\nReset your password: ${resetLink(user)}\nThis link expires in 1 hour.`;
}
function buildPromoEmail(user: User) {
  return `${greeting(user)}\n${promoCopy(user.segment)}\n${unsubscribeFooter(user)}${trackingPixel(user)}`;
}

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

Почему это работает

Почему встраивание — временное увеличение дублирования — ведёт к лучшей структуре? Потому что неправильная абстракция активно прячет информацию, нужную тебе, чтобы найти правильную. Пока три поведения свёрнуты за одной функцией с флагами, ты не видишь, какие части действительно общие, а какие случайны; ветки if затемняют настоящую форму. Встраивание раскладывает три случая бок о бок, полностью, так что настоящая ось вариации становится видна глазу. Только тогда ты можешь вынести по шву, который отражает, как код меняется, а не как он случайно выглядел в день, когда кто-то его слил. Дублирование — диагностический шаг, а не пункт назначения.

Частая ошибка

Режим отказа, ради предотвращения которого существует этот урок: отказ встраивать явно неправильную абстракцию, потому что «удалить её ощущается как нарушение DRY». Это наряжает смещение невозвратных затрат в язык принципа. DRY — инструмент удешевления изменения; абстракция, делающая каждое изменение дороже, уже провалила настоящую работу DRY, поэтому держать её — выбор беспринципный, а не принципиальный. Два признака, что ты рационализируешь: (1) ты защищаешь абстракцию тем, сколько труда стоило её построить, а не тем, во что обойдётся следующее изменение; (2) ты не можешь назвать ни одной причины, по которой все вызывающие изменились бы вместе. Если ни будущая стоимость, ни общая причина не выдерживают проверки — встраивай: дискомфорт говорит смещение, а не дизайн.

Проверь себя
Викторина

Общая функция принимает три булевых флага, каждый добавлен разным вызывающим, а её тело — клубок из if-ов. Коллега хочет добавить четвёртый флаг для нового случая. Каков senior-ход и почему?

Итог

Неправильная абстракция рождается слиянием двух вещей, похожих лишь случайно, а затем гниёт от обрастания — булев (или enum) флаг на каждого нового вызывающего, пока общий метод не станет нечитаемым клубком, связывающим несвязанные требования. Эвристика Сэнди Метц разрешает это: «дублирование намного дешевле неправильной абстракции», потому что стоимость дублирования ограничена, а стоимость неправильной абстракции нарастает по всем вызывающим. Лечение идёт против шерсти: встроить её обратно в каждого вызывающего, намеренно продублировав заново, чтобы настоящие различия стали видны, — а затем заново вывести подлинную общую концепцию (часто что-то меньшее и более внутреннее) из того, что действительно меняется вместе. Senior-настрой — удалять абстракцию без верности невозвратным затратам и называть режим отказа, когда он появляется: отказ встраивать, потому что «ощущается как нарушение DRY», — это смещение невозвратных затрат в одежде принципа. DRY служит изменяемости; когда оно перестаёт это делать, оно перестаёт применяться.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.