Единый уровень абстракции
Функция работает на одном уровне абстракции — высокоуровневая политика читается как повествование, вызывающее уровень ниже (правило понижения), а детали вынесены за имена, чтобы обозреть решение, не переключая контекст на каждой строке.
Ты открываешь 40-строчную функцию chargeSubscription. Тебе нужно ответить на один вопрос: при каких условиях она реально списывает деньги с карты? Но обозреть ответ не выходит, потому что политика — «если по плану просрочка и льготное окно истекло, списать» — переплетена с Math.floor((now - last) / 86_400_000), самодельной SQL-строкой и padStart, дополняющим номер счёта нулями. Каждая строка дёргает тебя вниз, на уровень байтов и строк, потом обратно вверх, на бизнес-уровень, потом снова вниз. Ты читаешь все сорок строк, чтобы извлечь три предложения логики.
Функция не неправильная — она уехала в прод, она списывает корректно. Она необозрима, и это конкретный, исправимый дефект. У причины есть имя: она смешивает уровни абстракции. У исправления имя тоже есть: сделать так, чтобы каждая функция говорила на одном уровне, и высокоуровневая читалась как повествование о решении, делегируя каждое «как» именованному вызову ниже.
После этого урока ты можешь замечать, когда функция смешивает высокоуровневую политику с низкоуровневой деталью; применять правило понижения — каждая функция работает на одном уровне и читается как повествование, вызывающее следующий уровень ниже; выносить детали за раскрывающие намерение имена, чтобы политика оставалась обозримой; и распознавать режим отказа, где избыточное выделение разбивает функцию на лабиринт однострочных косвенностей, который труднее читать, а не легче.
Функция смешивает уровни, когда бизнес-политика и машинная деталь делят одно тело. «Высокий уровень» — это что система решает: списать, повторить, отклонить, уведомить. «Низкий уровень» — это как машина делает механическую вещь: поделить миллисекунды, чтобы получить дни, склеить SQL-строку, дополнить число нулями, отформатировать дату. Это разные высоты мысли. Когда они сложены в одной функции, читателю не остаётся выбора, кроме как пролетать весь диапазон высот на каждой строке.
function chargeSubscription(sub: Subscription, now: number): void {
// high level: the decision
if (sub.status === "past_due") {
// low level: byte/date math, inline, mid-sentence
const daysLate = Math.floor((now - sub.lastPaidAt) / 86_400_000);
if (daysLate > 7) {
// low level: hand-built SQL
db.exec(
`UPDATE subs SET charged_at=${now} WHERE id='${sub.id}'`
);
// high level again: the action
gateway.charge(sub.cardToken, sub.amountCents);
}
}
}Чтобы выучить политику — просрочка, больше 7 дней, тогда списать, — тебе пришлось прочитать и мысленно отбросить 86_400_000, SQL и их синтаксис. Это отбрасывание и есть цена.
Правило понижения: каждая функция должна читаться как последовательность операторов на один уровень ниже неё. Формулировка Роберта Мартина в том, что программа читается сверху вниз, как проза: за каждой функцией следуют функции на один уровень ниже, чтобы можно было спускаться по одному шагу абстракции за раз. Внутри функции это значит, что её операторы должны находиться примерно на одной высоте, каждый делегируя следующее «как» вниз. Верхняя функция рассказывает историю; именованные вызовы держат детали.
function chargeSubscription(sub: Subscription, now: number): void {
if (!isPastDue(sub)) return;
if (daysOverdue(sub, now) <= GRACE_DAYS) return;
markCharged(sub, now);
gateway.charge(sub.cardToken, sub.amountCents);
}Теперь тело — это политика и только политика. daysOverdue и markCharged — это обещания: «тут есть какое-то как, но это не решение; спустись, если интересно». Читатель, обозревающий когда мы списываем, получает ответ в четырёх строках и ни разу не видит миллисекундную константу.
Выделение — это ход, а имя — полезная нагрузка. Вынос миллисекундной математики в daysOverdue помогает только потому, что имя говорит, что эта деталь означает на уровне выше. Читатель chargeSubscription меняет Math.floor((now - sub.lastPaidAt) / 86_400_000) на слово daysOverdue — фразу на языке политики. Деталь всё ещё существует; её просто переместили на уровень ниже и дали ярлык, который вызывающий может прочитать на своей высоте.
const MS_PER_DAY = 86_400_000;
function daysOverdue(sub: Subscription, now: number): number {
return Math.floor((now - sub.lastPaidAt) / MS_PER_DAY);
}
function markCharged(sub: Subscription, now: number): void {
// the SQL detail lives here, at the data-access level, not in the policy
db.update("subs", { id: sub.id }, { charged_at: now });
}Плохое имя сводит на нет всё упражнение: helper1 или doStuff всё равно заставляет вызывающего спуститься, чтобы узнать, что произошло. Выделение покупает обозримость только в той мере, в какой имя описывает деталь в словаре вызывающего.
Режим отказа: чрезмерное применение разбивает чтение на лабиринт однострочных косвенностей. Правило понижения — это подспорье для чтения, а не цель по числу строк. Если выделять, пока каждый двухстрочный фрагмент не станет своей функцией, то читателю, который всё-таки хочет деталь, придётся прыгать по пяти файлам, чтобы собрать одну мысль, а функция-политика вызывает имена настолько мелкозернистые, что они пересказывают код, а не резюмируют его.
// Over-extracted: each call hides almost nothing, so the reader
// must chase every name to learn anything — net reading cost UP.
function chargeSubscription(sub: Subscription, now: number): void {
if (shouldSkipBecauseNotPastDue(sub)) return;
if (isStillWithinGrace(sub, now)) return;
performTheChargeBookkeeping(sub, now);
invokeThePaymentGateway(sub);
}
function invokeThePaymentGateway(sub: Subscription): void {
gateway.charge(sub.cardToken, sub.amountCents); // one line, wrapped for no gain
}invokeThePaymentGateway добавляет прыжок, не добавляя концепции — исходный gateway.charge(...) уже был на уровне политики и уже самоописателен. Критерий не «короткая ли эта функция», а «называет ли каждое выделение концепцию, в которой вызывающий действительно хочет думать?» Выделяй деталь, когда её механизм — шум на уровне выше; встраивай её обратно, когда имя лишь повторит единственный вызов, который оно оборачивает. Понижение должно помогать чтению, а не дробить его.
Приведи реальную смешанную по уровням функцию к одному уровню. До — политика «отправлять напоминание о просрочке раз в день» погребена под форматированием даты, ручным SQL-upsert и сборкой строки:
function sendOverdueReminder(sub: Subscription, now: number): void {
// policy: only past due
if (sub.status !== "past_due") return;
// low level: did we already remind today? (ms math + date string)
const today = new Date(now).toISOString().slice(0, 10); // "2026-06-23"
const lastDay = new Date(sub.lastReminderAt).toISOString().slice(0, 10);
if (today === lastDay) return;
// low level: hand-built body + manual SQL
const amount = "$" + (sub.amountCents / 100).toFixed(2);
const body = "Your payment of " + amount + " is overdue.";
db.exec(
`UPDATE subs SET last_reminder_at=${now} WHERE id='${sub.id}'`
);
// policy: the action
mailer.send(sub.email, "Payment overdue", body);
}Чтобы найти правило — «просрочка, и сегодня ещё не напоминали, тогда письмо», — ты читаешь поверх трёх разных низкоуровневых техник. После — тело и есть правило, каждое «как» названо и понижено:
function sendOverdueReminder(sub: Subscription, now: number): void {
if (sub.status !== "past_due") return;
if (alreadyRemindedToday(sub, now)) return;
mailer.send(sub.email, "Payment overdue", overdueEmailBody(sub));
recordReminderSent(sub, now);
}
// one step down: each detail at its own level, named in the caller's vocabulary
function alreadyRemindedToday(sub: Subscription, now: number): boolean {
return sameCalendarDay(sub.lastReminderAt, now);
}
function overdueEmailBody(sub: Subscription): string {
return `Your payment of ${formatUsd(sub.amountCents)} is overdue.`;
}
function recordReminderSent(sub: Subscription, now: number): void {
db.update("subs", { id: sub.id }, { last_reminder_at: now });
}Верхняя функция теперь читается как четыре предложения политики. Заметь сдержанность: mailer.send(...) остался встроенным, потому что уже был на высоте политики и самоописателен — обернуть его значило бы добавить прыжок впустую. Мы выделили математику дат, сборку строки и SQL — три вещи, которые действительно были на уровень ниже решения, — и на этом остановились.
▸Почему это работает
Почему смешение абстракций бьёт именно по обзорности, если код выполняется одинаково в любом случае? Потому что чтение — это бюджет переключений контекста. Каждый раз, когда строка роняет тебя с «что мы решаем» на «как миллисекунды становятся днями», твоей рабочей памяти приходится подгружать и выгружать соответствующую ментальную модель. Функция на одном уровне позволяет держать единую модель — «политика списания» — на всю её длину; ты меняешь модель, лишь когда сознательно спускаешься в именованную деталь. Правило понижения не про число строк и даже не про переиспользование (эти детали могут вызываться единожды). Оно про то, чтобы дать читателю выбирать свою высоту, а не навязывать весь диапазон на каждой строке — именно это и делает 40-строчную функцию отвечаемой в 4-строчном обзоре.
▸Частая ошибка
Частая перекоррекция: трактовать «выделять, пока не станет коротко» как правило и в итоге получить chargeSubscription, вызывающий validateThePreconditions, вызывающий checkPastDueStatus, вызывающий getStatusField. Теперь читатель, которому нужно реальное условие, гонится через четыре прыжка, чтобы найти sub.status === "past_due" — косвенность стоит дороже, чем когда-либо стоила встроенная проверка. Два запаха сигналят об этом: обёртка, чьё имя лишь пересказывает свой единственный вызов (invokeThePaymentGateway вокруг gateway.charge), и функция, чьё тело — только вызовы других однострочных функций, которые ты написал. Выделяй деталь, потому что её механизм — шум на уровне выше, а не чтобы попасть в число строк. Когда имя лишь повторит единственную строку, которую оборачивает, встрой её обратно.
Ревьюер говорит, что 35-строчную функцию заказа «трудно обозреть». Она переплетает политику (когда отгружать) с встроенной налоговой математикой, самодельной SQL-строкой и форматированием даты. Какое самое точное исправление и что его ограничивает?
Функция необозрима, когда она смешивает уровни абстракции — когда бизнес-политика (списать, напомнить, отгрузить) переплетена с машинной деталью (миллисекундная математика, самодельный SQL, форматирование строк), вынуждая читателя переключать высоты на каждой строке. Исправление — это правило понижения: держать каждую функцию на одном уровне, чтобы она читалась как повествование о своём решении, и выделять каждое «как» за раскрывающее намерение имя на шаг ниже — имя и есть полезная нагрузка, потому что оно позволяет вызывающему читать деталь в своём словаре. Навык — это суждение, а не механика: выделяй деталь, когда её механизм — шум на уровне выше, но остановись прежде, чем избыточное выделение разобьёт функцию на лабиринт однострочных обёрток, которые лишь пересказывают код, — понижение должно дать читателю выбирать свою высоту, а не дробить ту, что он хотел.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.