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

Аргументы и побочные эффекты

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

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

Ты вызываешь getUser(id), и она работает. Позже ты обнаруживаешь, что где-то внутри она ещё и пишет строку в таблицу аудита и инкрементирует счётчик в Redis. Имя говорило get; тело получило, залогировало и помутировало. Теперь тесту, который «просто читает пользователя», нужны база данных и инстанс Redis, а вызывающий, которому нужен был пользователь дважды в цикле, нечаянно записал двенадцать строк аудита. Ничего не сломалось — пока ты не попытался переиспользовать функцию.

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

Цель

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

1

Стремись к 0–2 аргументам; три считай требующими причины, а 4+ — отсутствующей концепцией. Каждый параметр — это то, что читатель должен держать в голове, передать правильно и в правильном порядке. Ниладический (без аргументов) или монадический (с одним аргументом) вызов тривиален для чтения и тестирования; триадический вызов уже заставляет помнить, какой boolean был validate, а какой — notify. Это не жёсткий потолок — clamp(value, min, max) — отличная, связная триада. Эвристика — это порог запаха: при четырёх аргументах спроси, какую концепцию место вызова каждый раз пересобирает руками.

// 5 позиционных аргументов: зависят от порядка, нечитаемы в месте вызова
createOrder(customerId, items, 'express', true, null);
//                              ^ shipping  ^ gift  ^ coupon — где что?

Когда несколько параметров всегда путешествуют вместе, это не пять входов — это одна идея (запрос заказа), которую ещё не назвали.

2

Сверни длинный, связанный список параметров в объект-параметр. Когда параметры группируются — (x, y, width, height), (street, city, zip, country), пять полей заказа выше — введи тип, который называет эту группу. Место вызова становится самодокументируемым, потому что каждое значение помечено, порядок перестаёт иметь значение, а добавление шестого поля — это изменение типа, а не позиционного списка в каждом месте вызова.

interface OrderRequest {
  customerId: string;
  items: LineItem[];
  shipping: ShippingSpeed;
  gift: boolean;
  coupon?: string;
}

createOrder(req: OrderRequest): Order { /* ... */ }

// место вызова: помечено, без порядка, расширяемо
createOrder({ customerId, items, shipping: 'express', gift: true });

Выигрыш условный: он засчитывается только когда поля — настоящая концепция. Сложить несвязанные параметры в мешок не даёт ничего — см. вставку с ошибкой.

3

Булев флаговый аргумент почти всегда означает, что функция делает две вещи — разбей её. render(item, isAdmin) или save(user, true /* sendEmail */) читается так: в теле есть if (flag), который выбирает между двумя поведениями. Это две обязанности, делящие одно имя, а место вызова save(user, true) нечитаемо, пока не выследишь параметр. Лекарство — сделать два поведения двумя именованными функциями.

// до: одна функция, скрытая развилка, непрозрачное место вызова
function save(user: User, sendEmail: boolean) {
  db.write(user);
  if (sendEmail) mailer.welcome(user);
}
save(user, true);  // true... что есть true?

// после: каждое поведение названо; намерение видно в месте вызова
function saveUser(user: User) { db.write(user); }
function registerUser(user: User) { saveUser(user); mailer.welcome(user); }
registerUser(user);  // читается как есть

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

4

Не возвращай результаты через аргументы — возвращай их. Выходной аргумент — это параметр, который функция мутирует, чтобы вызывающий мог прочитать результат обратно. Он переворачивает естественное направление чтения: аргументы должны быть входами, поэтому appendFooter(report) выглядит так, будто потребляет report, тогда как на деле она правит его на месте. Читателю приходится знать тело, чтобы понять, кто владеет мутацией.

// выходной аргумент: report идёт ВНУТРЬ, возвращается изменённым — неожиданно
function addTotals(report: Report): void {
  report.total = report.lines.reduce((s, l) => s + l.amount, 0);
}

// разделение команд и запросов: запрос возвращает, эффект команды — в её имени
function totalFor(lines: Line[]): number {
  return lines.reduce((s, l) => s + l.amount, 0);
}
const report = { ...draft, total: totalFor(draft.lines) };

Если функция что-то вычисляет — верни это. Мутация аргумента прячет эффект за параметром, который выглядел как обычный вход.

5

Делай побочные эффекты явными и именованными; «выглядящая чистой» функция, которая втайне мутирует мир, — самая дорогая ложь, какую говорит сигнатура. Имя вроде parseConfig(text) обещает чистое преобразование: текст на входе, конфиг на выходе, без I/O. Если она ещё и пишет файл или делает сетевой вызов, каждый вызывающий наследует эффект, о котором не просил — тестам нужна реальная инфраструктура, ретраи дублируют запись, а рассуждение о программе ломается. Правило — разделение команд и запросов: функция либо отвечает на вопрос (запрос, чистая, возвращает значение), либо меняет мир (команда, возвращает void/эффект) — и её имя говорит, что именно.

// ЛОЖЬ: чисто выглядящее имя, скрытая запись
function parseConfig(text: string): Config {
  const cfg = JSON.parse(text);
  fs.writeFileSync('/var/cache/config.json', text); // сюрприз — побочный эффект!
  return cfg;
}

// ЧЕСТНО: запрос чист; команда называет свой эффект
function parseConfig(text: string): Config { return JSON.parse(text); }
function cacheConfig(text: string): void { fs.writeFileSync('/var/cache/config.json', text); }

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

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

Один вызов, три запаха, исправленные вместе. Вот функция, наросшая длинным списком, флагом и скрытым эффектом:

// уведомить пользователя; ничего полезного не возвращает, многое прячет
function notify(
  userId: string,
  message: string,
  channel: string,
  urgent: boolean,
  retries: number,
): void {
  const user = db.users.find(userId);      // скрытое чтение
  const body = urgent ? `URGENT: ${message}` : message;  // развилка по флагу
  send(channel, user.email, body, retries);
  audit.log('notify', userId, channel);    // скрытая запись
}

notify('u_1', 'Build failed', 'email', true, 3);  // true? 3? что это?

Место вызова нечитаемо: true и 3 — позиционные загадки, а тело тихо обращается к базе дважды (чтение и запись аудита) за именем, которое говорит лишь «notify». Флаг urgent развилкой меняет поведение, так что на деле это два уведомления под одним именем.

После рефакторинга — объект-параметр для группы, флаг вынесен отдельно, а побочный эффект назван и поднят под контроль вызывающего:

interface Notification {
  user: User;          // вызывающий резолвит пользователя — чтение теперь явное и переиспользуемое
  message: string;
  channel: Channel;
  retries?: number;
}

// почти-запрос-команда: одна работа (доставить), эффект назван, без скрытого чтения из БД
function deliver(n: Notification): DeliveryResult {
  return send(n.channel, n.user.email, n.message, n.retries ?? 3);
}

// поведение «urgent» — собственная именованная функция, а не флаг
function deliverUrgent(n: Notification): DeliveryResult {
  return deliver({ ...n, message: `URGENT: ${n.message}` });
}

// аудит — отдельная, видимая команда, которую вызывающий компонует, когда захочет
const result = deliverUrgent({ user, message: 'Build failed', channel: 'email' });
audit.log('notify', user.id, 'email');

Место вызова читается как английский, чтение из базы — явный выбор вызывающего (и тривиально мокаемый), срочность — именованная функция вместо true, а аудит — команда, на которую вызывающий подписывается, а не засада. Что сделало все три исправимыми разом: каждый запах был одной и той же низкой связностью — одна функция делает доставку и форматирование и персистентность и аудит — протекающей сквозь список параметров.

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

Почему скрытый побочный эффект хуже честного, а не просто «другой»? Потому что стоимость ложится на людей, которые никогда не читают тело. Сигнатура — это обещание о том, что ты должен предоставить и что получишь обратно; вызывающие, ревьюеры и авторы тестов планируют, опираясь на обещание, а не на реализацию. Когда parseConfig втайне пишет файл, каждый из них планирует неверно: автор теста не мокает файловую систему, к которой, как он не знал, обращаются, вызывающий, который зовёт её дважды, пишет дважды, ревьюер одобряет «чистое» изменение, в котором на деле есть I/O. Честный эффект — функция с именем writeConfigCache, возвращающая void, — не стоит ничего сверх, потому что обещание совпадает с реальностью. Дорогая часть побочного эффекта — никогда не сам эффект; это разрыв между тем, что заявляет имя, и тем, что делает код.

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

Соблазнительный провальный сценарий этого урока: ты берёшь вызов с 5 аргументами, оборачиваешь аргументы в { ... } и объявляешь победу — но объект оказывается чуланом хлама из несвязанных флагов ({ validate, sendEmail, dryRun, verbose, force }), а не настоящей концепцией. Ты не повысил связность; ты перенёс запах из списка параметров в мешок и, можно сказать, ухудшил, потому что теперь флаги необязательны и невидимы, а функция всё ещё делает пять вещей. Объект-параметр оправдывает себя только когда поля — настоящая, связная идея, путешествующая вместе (OrderRequest, Address, Rect). Если «объект» — просто куча переключателей поведения, настоящее лекарство то же, что и для флагов: разбить функцию. Сложить аргументы в мешок — лечить симптом (длинный список), игнорируя болезнь (одна функция делает много работ).

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

Ты видишь save(user, true), где второй аргумент переключает, отправлять ли приветственное письмо. Ревьюер предлагает «оберни аргументы в объект опций: save(user, { sendEmail: true })». Через призму связности — какой ход лучше и почему?

Итог

Список параметров функции и её эффекты — честный показатель её связности. Стремись к 0–2 аргументам, обосновывай три и читай 4+ как отсутствующую концепцию — сворачивай по-настоящему связную группу в объект-параметр. Булев флаговый аргумент означает, что функция делает две вещи; разбей её на две именованные функции. Не протаскивай результаты обратно через выходные аргументы — возвращай их, держа запросы чистыми, а команды честными об их эффекте (разделение команд и запросов). Прежде всего, делай побочные эффекты явными и именованными: выглядящая чистой функция, которая втайне пишет файл, — самая дорогая ложь, какую может сказать сигнатура, потому что стоимость ложится на всех, кто доверяет имени вместо чтения тела. И остерегайся провального сценария — объект опций, ставший чуланом хлама из несвязанных флагов, лишь переносит запах. Каждый из этих случаев — один и тот же диагноз, всплывающий в интерфейсе: низкая связность, функция, которая тихо делает больше одной работы.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.