В прошлом уроке призраки нашлись счётом: журнал записан руками, и каждое вычисление проверяется на столе. С опоздавшими кадрами так не вышло. Чтобы понять, опоздал ли кадр, нужно знать, который час, а часы это действие. Ринцвальд нашёл выход, которым пользуются почти все: подменить часы на время теста. Выход работает, пока тест один.
Этот урок о том, как сделать функцию, которой подмена не нужна вовсе. Такую функцию называют чистой, и проверяют её одной строкой без моков.
Подменённые часы
Вот функция Ринцвальда и два теста, записанные подряд, как они идут в файле:
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 в пятнадцатую секунду ещё не опоздал, потому что его повторили в двенадцатую, и с последней отправки прошло три секунды. В семнадцатую уже опоздал.
Что такое чистая функция
Функцию называют чистой, если выполняются два условия:
- Результат зависит только от аргументов. Никаких скрытых входов: часов, случайных чисел, общих переменных, окружения, провода.
- Снаружи после вызова ничего не изменилось. Никаких скрытых выходов: консоли, правки аргументов, записи в общие переменные, отправки кадров.
Чистая функция и вычисление из прошлого урока это одно и то же, сказанное двумя способами. «Вычисление» говорит, чем функция занята; «чистая» говорит, чего в ней нет.
У чистоты есть следствие, ради которого её и добиваются. Вызов чистой функции можно заменить его результатом, и программа этого не заметит. Где написано 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».
Дальше
Тесты Ринцвальда зелёные и больше не трогают часы. В кухню заходит Рыдкулл с доской башен Магистрали под мышкой: на доске у каждой башни отмечено, сколько раз её слышали и когда в последний раз.
Как значение, которое никто не трогал, оказалось другим, и как сделать, чтобы вчерашнее оставалось вчерашним, разберём в следующем уроке.