Раздел 46 · Effect: Тихая Академия

Чистые функции

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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

Этот урок о том, как сделать функцию, которой подмена не нужна вовсе. Такую функцию называют чистой, и проверяют её одной строкой без моков.

Подменённые часы

Вот функция Ринцвальда и два теста, записанные подряд, как они идут в файле:

import type { Entry } from './journal';

const overdue = (journal: readonly Entry[]): readonly number[] =>
  journal
    .filter((entry) => entry.kind === 'sent' && Date.now() - entry.at >= 5000)
    .map((entry) => entry.frame);

const stamp = (text: string): string => `${new Date(Date.now()).toISOString()} ${text}`;

const journal: readonly Entry[] = [
  { kind: 'sent', frame: 1, at: 1000 },
  { kind: 'sent', frame: 2, at: 12_000 },
];

// тест 1: подменяем часы
Date.now = () => 17_000;
console.log(overdue(journal));

// тест 2: про часы ничего не знает
console.log(stamp('смена закрыта'));
[ 1, 2 ]
1970-01-01T00:00:17.000Z смена закрыта

Первый тест получил то, что хотел. Второй получил семнадцатую секунду первого января 1970 года, потому что Date.now после первого теста так и остался подменённым. Ринцвальд забыл вернуть часы на место, но дело не в забывчивости. Чтобы проверить overdue, пришлось поменять мир, в котором живут все тесты сразу. Каждая такая подмена это действие внутри теста, и тесты начинают зависеть друг от друга, от порядка и от того, кто за кем убрал.

У overdue есть вход, которого не видно в сигнатуре. Сигнатура обещает: дай журнал, получишь номера кадров. А ответ зависит ещё и от часов. Такой вход называют скрытым. Его нельзя передать, его можно только подменить.

Время приходит аргументом

Матушка делает то же, что в прошлом уроке сделал компилятор с now: скрытый вход переезжает в аргументы.

export const overdue = (
  journal: readonly Entry[],
  now: number,
  timeoutMs: number,
): readonly number[]

Теперь «который час» это число, которое передал вызывающий. Проверка опоздавших кадров становится вычислением, и тест больше не трогает мир:

import { type Entry, overdue } from './journal';

const journal: readonly Entry[] = [
  { kind: 'sent', frame: 1, at: 1000 },
  { kind: 'sent', frame: 2, at: 5000 },
  { kind: 'acked', frame: 1, at: 1500 },
  { kind: 'sent', frame: 3, at: 9000 },
  { kind: 'sent', frame: 2, at: 12_000 },
];

console.log(overdue(journal, 15_000, 5000));
console.log(overdue(journal, 17_000, 5000));
[ 3 ]
[ 2, 3 ]

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

Что такое чистая функция

Функцию называют чистой, если выполняются два условия:

  1. Результат зависит только от аргументов. Никаких скрытых входов: часов, случайных чисел, общих переменных, окружения, провода.
  2. Снаружи после вызова ничего не изменилось. Никаких скрытых выходов: консоли, правки аргументов, записи в общие переменные, отправки кадров.

Чистая функция и вычисление из прошлого урока это одно и то же, сказанное двумя способами. «Вычисление» говорит, чем функция занята; «чистая» говорит, чего в ней нет.

У чистоты есть следствие, ради которого её и добиваются. Вызов чистой функции можно заменить его результатом, и программа этого не заметит. Где написано overdue(journal, 17_000, 5000), можно написать [2, 3], и ничего не поменяется: ни ответы, ни консоль, ни журнал. Это свойство называют ссылочной прозрачностью. С Date.now() так нельзя: заменишь его на число, и программа будет жить в одной секунде навсегда.

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

Что булькает внутри котла

Ринцвальд прищуривается: в эталоне phantomAcks из прошлого этапа внутри цикл и Set.add. Это же мутация, разве так можно в чистой функции?

import type { Entry } from './journal';

const countSent = (journal: readonly Entry[]): number => {
  let count = 0;
  for (const entry of journal) {
    if (entry.kind === 'sent') count += 1;
  }
  return count;
};

Можно. Чистота говорит о том, что видно снаружи. count рождается при вызове и умирает вместе с ним, второй вызов получает свой count с нуля, и никто снаружи о нём не знает. Сравни с relayed из прошлого урока: он жил снаружи и переживал вызовы. Матушка говорит так: что булькает внутри котла, никого не касается, пока не выплёскивается.

План вместо поступка

Опоздавшие кадры нужно не только найти, но и отправить заново. Отправка это действие, и в котёл её не положишь. Зато можно положить в котёл решение, что отправлять:

export const retryPlan = (
  journal: readonly Entry[],
  now: number,
  timeoutMs: number,
): readonly Entry[]

retryPlan возвращает новые записи журнала: по одной записи sent на каждый опоздавший кадр, с моментом now. Это план, данные. Исполнит его тонкое действие снаружи, одной строкой await wire.send('T-1', plan.map((entry) => entry.frame)), а план можно проверить, распечатать, отдать Рыдкуллу на подпись или выбросить. Это тот же ход, что в уроке 03: сначала записать, потом произносить. Там это было про промисы, здесь про то, как устроен твой собственный модуль.

Где теперь часы

Часы никуда не делись: кто-то должен спросить, который час. Спрашивает их тонкое действие на краю, один раз за смену, и раздаёт ответ всем вычислениям:

async function checkShift(wire: Wire): Promise<void> {
  const journal = (await wire.read('T-1')).map(parseLine);
  const now = Date.now();
  console.log(`опоздали: ${overdue(journal, now, TIMEOUT_MS).join(', ')}`);
  const plan = retryPlan(journal, now, TIMEOUT_MS);
  await wire.send('T-1', plan.map((entry) => entry.frame));
}

Один вопрос к часам вместо двух даёт больше, чем кажется. Если бы overdue и retryPlan спрашивали часы сами, журнал в консоли и план повтора жили бы в разных мгновениях. Кадр, отправленный ровно пять секунд назад, мог бы оказаться «ещё не опоздавшим» в 16 999 мс и «опоздавшим» в 17 000 мс: консоль сказала бы, что опоздавших нет, а повтор всё равно ушёл бы. Два ответа об одной ночи, и оба честные. С одним now на всю смену такого не бывает: все вычисления смотрят на одно и то же мгновение.

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

Перевод с Ук

Ринцвальд поменял сигнатуру overdue и пошёл по старым местам, где её звали:

import { type Entry, overdue } from './journal';

declare const journal: readonly Entry[];

const late = overdue(journal, 5000);
call18.ts(5,14): error TS2554: Expected 3 arguments, but got 2.

Строка 5, колонка 14, TS2554: функция ждёт три аргумента, а получила два. Старый вызов передавал журнал и таймаут, а время брал у часов. Теперь часов внутри нет, и компилятор нашёл каждое место, где их раньше подсматривали. Пройдись по всем таким ошибкам, и в каждом месте придётся решить, какой момент ты имеешь в виду: «сейчас» для ночной смены, «конец смены» для отчёта, «семнадцатая секунда» для теста.

Заметь ещё одно. В этом вызове 5000 попало бы в now, а не в timeoutMs: аргументы встают по порядку. Если бы компилятор промолчал, функция считала бы, что сейчас пятая секунда. Чем больше у функции чисел подряд, тем легче их перепутать; в EF1 ты видел, как от этого спасают бренды.

Где тут дыра

import { type Entry, phantomAcks } from './journal';

const overdue = (journal: Entry[], now: number, timeoutMs: number): number[] =>
  journal
    .sort((a, b) => a.at - b.at)
    .filter((entry) => entry.kind === 'sent' && now - entry.at >= timeoutMs)
    .map((entry) => entry.frame);

const night: Entry[] = [
  { kind: 'sent', frame: 1, at: 1000 },
  { kind: 'acked', frame: 40, at: 1200 },
  { kind: 'sent', frame: 40, at: 900 },
];

console.log(phantomAcks(night));
console.log(overdue(night, 9000, 5000));
console.log(phantomAcks(night));
[ 40 ]
[ 40, 1 ]
[]

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

Чистая версия не трогает аргумент: journal.toSorted(...) возвращает новый массив и оставляет старый как был. А ещё лучше попросить, чтобы компилятор не дал отсортировать журнал на месте вовсе. Как это сделать, разберём в следующем уроке.

Котёл, этап 2

Котёл учится опоздавшим кадрам. Ты напишешь overdue, которая получает момент «сейчас» аргументом, и retryPlan, которая возвращает план повтора записями журнала. Тесты подменят Date.now и Math.random функциями, которые бросают исключение: если вычисление посмотрит на часы или бросит кости, этап упадёт.

Практика

Глубже в архиве

Почему подмена всего подряд в тестах превращается в болезнь и чем её лечат, разбирает урок «Дубли: классификация, овермок и in-memory fakes». Мемоизация, которая запоминает результат и не считает второй раз, честно работает только на чистых функциях; она есть в уроке «ФП-стиль в JavaScript».

Дальше

Тесты Ринцвальда зелёные и больше не трогают часы. В кухню заходит Рыдкулл с доской башен Магистрали под мышкой: на доске у каждой башни отмечено, сколько раз её слышали и когда в последний раз.

Как значение, которое никто не трогал, оказалось другим, и как сделать, чтобы вчерашнее оставалось вчерашним, разберём в следующем уроке.