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

Действия, вычисления, данные

middle~30 мин

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

* Ведьмы с гор колдуют редко, и соседи думают, что они не умеют. Ведьмы знают другое: почти любое колдовство можно заменить тем, что ты посмотрел, посчитал и сказал человеку правду в лицо. Это дешевле, это не взрывается, и это можно повторить завтра. Называется головологией, и учатся ей дольше, чем колдовству.

Под утро на башню T-1 пришёл кадр T-7->T-1|41|Ack|40: подтверждение сорокового кадра. Протокол из прошлого раздела принял его, потому что кадр написан по правилам. Правду ли он говорит, протокол знать не может: правда о том, что башня отправляла, лежит не в кадре, а в журнале башни. Подтверждение кадра, который башня не посылала, мы будем звать призраком.

Ринцвальд взялся ловить призраков и за вечер написал проверку ночной смены. Матушка Воскова, ведьма с гор, приехала в Академию утром и первым делом попросила показать её. Этот урок о том, почему проверку, которая считает правильно, всё равно нельзя проверить, и как это починить, не написав ни одного мока.

Скоропись Ринцвальда

let relayed = 0;

const TIMEOUT_MS = 5000;
async function checkShift(wire: Wire): Promise<void> {
  const lines = await wire.read('T-1');
  const now = Date.now();
  const journal = lines.map(parseLine);
  const sent = journal.filter((entry) => entry.kind === 'sent');
  relayed += sent.length;
  const phantoms = journal.filter(
    (entry) => entry.kind === 'acked' && !sent.some((s) => s.frame === entry.frame),
  );
  console.log(`призраков: ${phantoms.length}`);
  const late = sent.filter((entry) => now - entry.at >= TIMEOUT_MS);
  await wire.send('T-1', late.map((entry) => entry.frame));
}

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

Работает? Работает. Ринцвальд гонял её прошлой ночью, и в консоли было «призраков: 1». А теперь попробуй ответить Матушке: считает ли checkShift призраков верно, если подтверждение пришло раньше отправки? Если журнал пустой? Если один кадр подтвердили трижды? Чтобы узнать, функцию придётся вызвать. Вызвать её значит прочитать настоящий провод, отправить настоящие повторы и сдвинуть общий счётчик. Проверка, которую нельзя проверить, не проверяя заодно всю Магистраль, это не проверка, а надежда.

Что меняется от вызова к вызову

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

  • wire.read('T-1') во второй раз может вернуть другие строки: за секунду на провод что-то пришло.
  • Date.now() вернёт другое число, хотя ты ничего не менял.
  • relayed вырастет второй раз: после двух вызовов в нём вдвое больше кадров, чем отправила башня.
  • В консоли станет две строки.
  • Повторы уйдут дважды, и башня T-7 получит одни и те же кадры два раза.

А теперь возьми строку journal.filter((entry) => entry.kind === 'sent'). Дай ей один и тот же журнал сто раз, и сто раз она вернёт одни и те же кадры. Ей всё равно, ночь сейчас или утро, первый это вызов или сотый, и после неё в мире ничего не изменилось.

Вот и всё различие, на котором стоит раздел. Код делится на три сорта.

Действие зависит от того, когда и сколько раз его вызвали. Оно читает мир (провод, часы, файл) или меняет его (консоль, счётчик, отправка). Вызови дважды, и получишь другое или натворишь вдвое больше.

Вычисление превращает вход в выход. Тот же вход всегда даёт тот же выход, а снаружи после вычисления всё как было. Вызвать его сто раз так же безопасно, как один, и так же безопасно не вызвать вовсе.

Данные ничего не делают. Это факты, записанные значением: 5000, строка с провода, запись { kind: 'sent', frame: 41, at: 1030 }. Их читают, сравнивают и передают дальше.

Деление взято из книги Эрика Норманда «Grokking Simplicity», и на нём держится весь раздел. Матушка говорит короче: «Что делает, то и спрашивай, когда. Что считает, у того спрашивай только, из чего».

Раскрась сам

Ниже та же скоропись. Покрась каждую строку в один из трёх цветов, потом нажми «Проверить». Заголовок функции, скобки и объявление relayed красить не нужно.

Разбор

Вот как раскрашивает Матушка:

let relayed = 0;

const TIMEOUT_MS = 5000; // данные
async function checkShift(wire: Wire): Promise<void> {
  const lines = await wire.read('T-1'); // действие
  const now = Date.now(); // действие
  const journal = lines.map(parseLine); // вычисление
  const sent = journal.filter((entry) => entry.kind === 'sent'); // вычисление
  relayed += sent.length; // действие
  const phantoms = journal.filter(
    (entry) => entry.kind === 'acked' && !sent.some((s) => s.frame === entry.frame),
  ); // вычисление
  console.log(`призраков: ${phantoms.length}`); // действие
  const late = sent.filter((entry) => now - entry.at >= TIMEOUT_MS); // вычисление
  await wire.send('T-1', late.map((entry) => entry.frame)); // действие
}

Чаще всего спотыкаются на трёх строках.

const now = Date.now() выглядит безобидно: ничего не пишет, ничего не отправляет. Но ответ зависит от того, когда спросили, а это и есть признак действия. Чтение мира такое же действие, как запись в него.

relayed += sent.length это сложение, а сложение считается вычислением. Только складывает оно в переменную, которая живёт снаружи функции и переживает её. После второго вызова в relayed другое число, чем после первого, хотя журнал был тот же. Значит, действие.

const late = ... использует now, то есть время. Почему вычисление? Потому что в этой строке now уже не часы, а число, которое кто-то спросил у часов строкой выше. Строка сравнивает два числа и вычитает. Дай ей те же sent и тот же now, и она вернёт тех же опоздавших. Время перестаёт быть действием в ту секунду, когда его записали в значение. Этот ход понадобится в следующем уроке.

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

async в сигнатуре часто подсказывает, что внутри действие, но не доказывает этого. Функция может быть асинхронной и при этом только считать, и может быть синхронной, но писать в консоль. Сорт определяет то, что функция делает, а не то, как она объявлена. В разделе EF4 действия станут значениями типа Effect, и различие начнёт видеть компилятор. Пока различаем глазами.

Вынуть вычисление

Правило одно: вычислению дают всё, что ему нужно, аргументами, и забирают у него всё, что оно сделало, возвращаемым значением. Ничего не берут тайком и ничего не оставляют снаружи.

Журнал становится данными с описанным видом, а подсчёты становятся функциями от журнала:

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

const night: readonly Entry[] = [
  { kind: 'sent', frame: 1, at: 100 },
  { kind: 'sent', frame: 2, at: 200 },
  { kind: 'acked', frame: 1, at: 250 },
  { kind: 'acked', frame: 40, at: 300 },
];

console.log(unconfirmed(night));
console.log(phantomAcks(night));
console.log(shiftReport(night));
console.log(shiftReport(night));
[ 2 ]
[ 40 ]
отправлено 2, без ответа 1, призраков 1
отправлено 2, без ответа 1, призраков 1

Вот и ответ Матушке. Журнал записан на столе руками, провода нет, часов нет, и каждый её вопрос проверяется за строку: подтверждение раньше отправки, пустой журнал, тройное подтверждение одного кадра. shiftReport вызвана дважды и дважды сказала одно и то же. Призрак найден счётом, без единого заклинания.

Что осталось от checkShift? Тонкое действие, которое читает провод, отдаёт строки вычислению и печатает ответ:

async function checkShift(wire: Wire): Promise<void> {
  const journal = (await wire.read('T-1')).map(parseLine);
  console.log(shiftReport(journal));
}

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

Перевод с Ук

С опоздавшими кадрами так легко не вышло. Ринцвальд вынес строку late из checkShift в отдельную функцию, как велела Матушка:

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

const TIMEOUT_MS = 5000;

const lateFrames = (sent: readonly Entry[]): readonly number[] =>
  sent.filter((entry) => now - entry.at >= TIMEOUT_MS).map((entry) => entry.frame);
late.ts(6,26): error TS2304: Cannot find name 'now'.

Строка 6, колонка 26, TS2304: «не могу найти имя now». Пока строка жила внутри checkShift, now был рядом, строкой выше, и строка брала его, ни у кого не спрашивая. Вынесли её наружу, и оказалось, что у неё был вход, которого нет в списке аргументов. Компилятор нашёл скрытый вход и назвал его по имени.

Это лучшее, что может случиться при вынимании вычисления. Каждое такое «не могу найти» показывает, что функция тайком брала у окружения. Чинится одинаково: имя переезжает в аргументы, (sent, now), и функция снова собирается. Константу TIMEOUT_MS компилятор нашёл, потому что она объявлена рядом. Она данные, и брать её из модуля можно; но если таймаут однажды начнёт приходить из настроек, он тоже станет аргументом. Как сделать время аргументом и что это даёт тестам, разберём в следующем уроке.

Где тут дыра

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

const seen = new Set<number>();

const phantomAcks = (journal: readonly Entry[]): readonly number[] => {
  const sent = new Set(journal.filter((entry) => entry.kind === 'sent').map((entry) => entry.frame));
  const fresh = journal.filter(
    (entry) => entry.kind === 'acked' && !sent.has(entry.frame) && !seen.has(entry.frame),
  );
  for (const entry of fresh) seen.add(entry.frame);
  return fresh.map((entry) => entry.frame);
};

const night: readonly Entry[] = [
  { kind: 'sent', frame: 1, at: 100 },
  { kind: 'acked', frame: 40, at: 300 },
];

console.log(phantomAcks(night));
console.log(phantomAcks(night));
[ 40 ]
[]

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

Ломается оно не ночью, а там, где Ринцвальд не ждёт. Отчёт утренней смены зовёт phantomAcks на том же журнале и получает пусто. Тесты зависят от порядка: тест про сороковой кадр проходит, если идёт первым, и падает, если перед ним кто-то уже спросил о том же журнале.

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

Котёл, этап 1

Котёл Матушки начинается с журнала башни. Ты опишешь запись журнала как данные и напишешь три вычисления от журнала: кадры без ответа, призраки и сводку смены. Провода, часов и консоли в модуле нет совсем, поэтому каждое вычисление проверяется на журнале, записанном в тесте руками.

Практика

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

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

Дальше

Матушка листает журнал, записанный руками, и кивает: призрак один, сороковой. Ринцвальд смотрит на строку с опоздавшими кадрами.

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