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

Маленькие функции, одно дело

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

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

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

Лекарство, к которому все тянутся, — «сделать функции маленькими». Совет правильный, но по неправильной причине. Дело не в меньшем числе строк — дело в том, что функция должна делать одно дело, а у «одного дела» есть точный смысл, который senior-инженер может отстоять: одна причина для изменения, выраженная на одном уровне абстракции. Ошибись в другую сторону — и ты искрошишь связную логику на дюжину однострочных функций, за которыми гоняешься по всему файлу. Этот урок — про то, как попасть в середину.

Цель

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

1

«Одно дело» означает одну причину для изменения, на одном уровне абстракции — а не одну строку. Обработчик, который парсит, валидирует, сохраняет и уведомляет, имеет четыре причины для изменения: формат данных на проводе, бизнес-правила, схему хранения и канал сообщений. Каждая может двигаться независимо. Когда они делят одно тело функции, изменение любой из них заставляет читать сквозь остальные три и рисковать задеть их. Запах — не длина, а то, что строки работают на разных высотах: высокоуровневая оркестрация («затем мы уведомляем пользователя») стоит inline рядом с низкоуровневой деталью (new Date().toISOString(), параметры SMTP). Глазу приходится постоянно переключать передачи.

// одна функция, четыре причины для изменения, смешанные высоты
async function handleSignup(req: Request): Promise<Response> {
  const body = await req.json();
  if (!body.email || !body.email.includes("@")) return bad("email");
  if (!body.password || body.password.length < 8) return bad("password");
  const id = crypto.randomUUID();
  await db.execute("INSERT INTO users(id,email,pw) VALUES(?,?,?)",
    [id, body.email, await hash(body.password)]);
  await mailer.send({ to: body.email, subject: "Welcome", body: tmpl(id) });
  return new Response(JSON.stringify({ id }), { status: 201 });
}
2

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

async function handleSignup(req: Request): Promise<Response> {
  const input = await parseSignup(req);       // формат на проводе
  const error = validateSignup(input);        // бизнес-правила
  if (error) return bad(error);
  const user = await persistUser(input);      // хранение
  await sendWelcome(user);                     // сообщения
  return created({ id: user.id });
}

Теперь верхняя функция — это четыре названных шага. Чтобы изменить валидацию, ты открываешь validateSignup и больше ничего не читаешь. Чтобы изменить письмо — открываешь sendWelcome. Оркестрация читается как предложение; каждая деталь живёт на уровень ниже, где ей и место.

3

Выигрыш — это имя, а не меньшее тело. Вот часть, которую люди упускают. validateSignup ценна, даже если в ней всего три строки, потому что имя утверждает намерение: «этот пробег кода — шаг валидации». Выделенная функция — это документация, которую компилятор держит честной: она не может разойтись с кодом так, как может комментарий. Хорошее выделение заменяет комментарий-плюс-блок названным вызовом:

// до: комментарий — единственное, что говорит тебе, что это за блок
// валидируем входные данные регистрации
if (!body.email || !body.email.includes("@")) return bad("email");
if (!body.password || body.password.length < 8) return bad("password");

// после: имя несёт то же намерение и не может устареть
const error = validateSignup(body);
if (error) return bad(error);

Если выделение блока не позволяет дать ему имя информативнее, чем строки внутри него, то выделение ничего не покупает. Тест на «настоящее ли это под-намерение»: могу ли я назвать его по концепции, которая уже заботит читателя? «Валидировать регистрацию», «сохранить пользователя», «отправить приветствие» — да. «Строки с 12 по 17» — нет.

4

Избыточное выделение — равный и противоположный отказ: крошение связности в погоню за функциями. Как только «маленькое — хорошо» становится рефлексом, люди выделяют однострочники без независимого намерения — function isEmpty(s) { return s.length === 0 }, function addOne(n) { return n + 1 } — и расщепляют единое связное вычисление на шесть функций, по которым приходится прыгать, чтобы понять смысл. Теперь прочитать одну идею значит гоняться за шестью определениями по всему файлу, держа стек вызовов в голове. Это менее связно, а не более: ты увеличил сцепление между фрагментами, разрушив локальность, которая позволяла читать целое сверху вниз.

// избыточно выделено: одна связная формула, разбросанная по файлу
function priceFor(item: Item): number {
  return applyTax(applyDiscount(base(item)));   // надо гнаться за 3 определениями
}
function base(i: Item) { return i.qty * i.unit; }
function applyDiscount(x: number) { return x * 0.9; }   // 0.9? для кого?
function applyTax(x: number) { return x * 1.2; }        // скрытая магия

У этих фрагментов нет независимых причин для изменения — скидка, налог и базовая цена — это одно правило ценообразования. Встроенное, всё правило читается в четыре строки в одном месте. Эвристика: выделяй, когда у фрагмента есть своя причина для изменения или он переиспользуется; не выделяй просто чтобы укоротить родителя. Связность — то, что меняется вместе, живёт вместе — это цель по обе стороны.

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

Выдели обработчик на 40 строк в четыре названных шага — и остановись на четырёх. Начни с функции-бога из шага 1: она inline парсит, валидирует, сохраняет и уведомляет. Каждое — настоящее под-намерение со своей причиной для изменения, поэтому каждое заслуживает именованной функции:

// parse.ts — владеет форматом на проводе
function parseSignup(req: Request): Promise<SignupInput> {
  return req.json() as Promise<SignupInput>;
}

// validate.ts — владеет бизнес-правилами; возвращает первую проблему или null
function validateSignup(input: SignupInput): string | null {
  if (!input.email?.includes("@")) return "email";
  if ((input.password?.length ?? 0) < 8) return "password";
  return null;
}

// users.ts — владеет хранением
async function persistUser(input: SignupInput): Promise<User> {
  const user = { id: crypto.randomUUID(), email: input.email };
  await db.execute("INSERT INTO users(id,email,pw) VALUES(?,?,?)",
    [user.id, user.email, await hash(input.password)]);
  return user;
}

// notify.ts — владеет сообщениями
async function sendWelcome(user: User): Promise<void> {
  await mailer.send({ to: user.email, subject: "Welcome", body: tmpl(user.id) });
}

// handler.ts — владеет только оркестрацией
async function handleSignup(req: Request): Promise<Response> {
  const input = await parseSignup(req);
  const error = validateSignup(input);
  if (error) return bad(error);
  const user = await persistUser(input);
  await sendWelcome(user);
  return created({ id: user.id });
}

Каждая функция делает одно дело на одной высоте, а её имя документирует шаг. Заметь, где мы остановились: мы не стали выделять function isValidEmail(e) { return e.includes("@") } из validateSignup. Две проверки внутри validateSignup — это одна связная идея — «приемлемы ли эти поля регистрации» — и они меняются вместе. Их разделение купило бы более короткое тело ценой погони за функциями, того самого избыточного выделения из шага 4. Четыре шага, четыре причины для изменения, и валидация читается в одном месте: вот та форма, к которой нужно стремиться.

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

Почему «одна причина для изменения» — правильное определение вместо лимита на строки? Потому что число строк — это прокси, который ломается с обоих концов. Функция на 60 строк может законно делать одно дело — единый конечный автомат, парсер, расчёт раскладки, — где каждая строка служит одной связной цели, и выделение кусков лишь затемнит её. А функция на 6 строк может делать три дела, и все плохо. Подсчёт строк оптимизирует симптом; подсчёт причин для изменения оптимизирует то реальное свойство, которое тебя заботит (стоимость следующего изменения, из первого урока этого юнита). Когда тест на причины-для-изменения и инстинкт на число строк расходятся, доверяй причинам-для-изменения — именно их на самом деле измеряют связность и принцип единой ответственности.

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

Соблазнительная ошибка — считать «выделить функцию» всегда-хорошим и гнать число строк к нулю. Это рождает код, который выглядит дисциплинированным в любой отдельной функции и невыносим для чтения как целое, потому что понять одно поведение теперь требует восстановить дерево вызовов, размазанное по файлу. Признак для ревьюера: если приходится прыгать к определению, чтобы узнать то, что хорошо названное inline-выражение сказало бы прямо на месте, выделение отняло ценность. Выделение — инструмент для именования намерений и изоляции причин для изменения, а не счёт, который надо максимизировать. Останавливайся, когда каждая функция называет концепцию, которая заботит читателя, — не на срез раньше и не на срез позже.

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

Функция на 50 строк реализует единый конечный автомат ценообразования: каждая строка участвует в одном связном вычислении, которое меняется лишь когда меняются правила ценообразования. Коллега настаивает, что её надо разбить, потому что «функции должны быть меньше 20 строк». Каков senior-выбор?

Итог

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

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.