В прошлом уроке у башни T-1 появилась доска: для каждой соседки записано, сколько раз её слышали и когда в последний раз. Рыдкулл смотрит на доску и задаёт вопросы, и каждый вопрос это условие на башню. Условия меняются чаще, чем доска.
Этот урок о том, как обращаться с условием так же, как с числом или строкой: положить в переменную, передать в функцию, вернуть из функции и сложить из двух условий третье. Тогда новое требование Рыдкулла станет новой маленькой функцией, а не новой веткой в старой.
Флажки Ринцвальда
Ринцвальд ответил на первые два вопроса одной функцией:
import type { Board } from './journal';
export const silentTowers = (
board: Board,
now: number,
quietMs: number,
minHeard: number,
): readonly string[] => {
const codes: string[] = [];
for (const [code, tower] of Object.entries(board)) {
if (now - tower.lastAt >= quietMs && tower.heard >= minHeard) codes.push(code);
}
return codes;
};
Работает, и даже чисто: время пришло аргументом, доска не тронута. Но посмотри, что будет с завтрашним «или почти не слышны». Нужен ещё параметр, порог «почти», и флажок, который переключает && на ||. Послезавтра Рыдкулл захочет «молчат, но не T-1», и параметров станет семь. Каждый вызов превращается в ряд чисел, где легко перепутать quietMs с minHeard, а каждое новое условие лезет внутрь цикла, который уже проверен.
Цикл здесь ни при чём, он всегда одинаковый: пройти доску и оставить подходящие коды. Меняется только вопрос «подходит ли эта башня». Вынеси вопрос наружу, и цикл больше не придётся трогать.
Условие как значение
Вопрос «подходит ли башня» это функция из башни в true или false. Её можно записать в константу, как число:
const isQuiet = (tower: Tower) => 60_000 - tower.lastAt >= 30_000;
А цикл можно написать один раз, и вопрос он получит параметром:
export type Predicate<A> = (value: A) => boolean;
export const towersWhere = (board: Board, predicate: Predicate<Tower>): readonly string[] =>
Object.entries(board)
.filter(([, tower]) => predicate(tower))
.map(([code]) => code);
towersWhere(board, isQuiet);
Функцию, которая отвечает «да» или «нет» про значение, называют предикатом. towersWhere принимает предикат аргументом, и такие функции называют функциями высшего порядка. Звучит торжественно, а ты пишешь их каждый день: filter, map, sort с компаратором, setTimeout принимают функцию. В TypeScript функция это значение, как любое другое, и с ним можно делать всё, что с числом: хранить, передавать, возвращать.
Теперь у towersWhere два параметра, и они никогда не меняются. Новое требование Рыдкулла это новый предикат рядом, а не правка внутри.
Замыкание
У isQuiet вшиты два числа: «сейчас» это 60_000, а «молчит» это полминуты. Вшитое «сейчас» никуда не годится. Время в котле приходит аргументом, это правило урока 18. Но towersWhere вызывает предикат с одной башней, второго аргумента для «сейчас» у него нет.
Выход в том, чтобы функция вернула функцию:
export const quietFor =
(now: number, quietMs: number): Predicate<Tower> =>
(tower) =>
now - tower.lastAt >= quietMs;
Вызов quietFor(60_000, 15_000) ничего не проверяет: он берёт момент и порог и отдаёт предикат, который ждёт башню. Внутренняя стрелка читает now и quietMs, хотя её саму вызовут позже и с одной башней. Функцию, которая держит переменные места, где её создали, называют замыканием.
Два вызова дают два независимых предиката:
const board: Board = {
'T-2': { heard: 5, lastAt: 10_000 },
'T-3': { heard: 2, lastAt: 40_000 },
'T-4': { heard: 9, lastAt: 55_000 },
};
const day = quietFor(60_000, 15_000);
const night = quietFor(60_000, 45_000);
console.log(towersWhere(board, day), towersWhere(board, night));
[ 'T-2', 'T-3' ] [ 'T-2' ]
day помнит порог в пятнадцать секунд, night в сорок пять, и друг другу они не мешают. При этом оба остаются вычислениями: момент 60_000 им передали, на часы они не смотрят. Тот же предикат на ту же башню всегда ответит одинаково.
Так же устроены heardAtLeast(times) для числа голосов и kindIs(kind) для записей журнала: функция берёт настройку и возвращает предикат.
Где тут дыра
const now = 60_000;
let quietMs = 30_000;
const isQuiet = (tower: Tower) => now - tower.lastAt >= quietMs;
const reports = { day: isQuiet };
console.log(towersWhere(board, reports.day));
quietMs = 15_000; // ночному отчёту порог помягче
console.log(towersWhere(board, isQuiet));
console.log(towersWhere(board, reports.day));
[ 'T-2' ]
[ 'T-2', 'T-3' ]
[ 'T-2', 'T-3' ]
Первые две строки честные: дневной отчёт, потом ночной с порогом помягче, и в ночной попала T-3. Третья строка это снова дневной отчёт из таблицы reports, который никто не трогал. Час назад в нём была одна T-2, теперь там и T-3. Ринцвальд поменял порог для ночи, а дневной предикат поменялся вместе с ним.
Замыкание помнит переменную, а не её значение в момент создания. isQuiet читает quietMs каждый раз, когда его зовут, и видит последнее, что туда записали. Пока предикат вызывают сразу, это незаметно. Положи его в таблицу и вызови через час, и он ответит на вопрос, которого ему не задавали. Это то же общее изменяемое состояние, что в уроке 19, только спрятанное внутри функции.
У quietFor(now, 30_000) такой беды нет: параметры каждого вызова свои, и снаружи до них не дотянуться. Хочешь другой порог, зови quietFor ещё раз.
Собрать из маленьких
Остались связки «и», «или», «не». Их тоже можно написать функциями, которые берут предикаты и возвращают предикат:
export const not =
<A>(predicate: Predicate<A>): Predicate<A> =>
(value) =>
!predicate(value);
allOf(p, q, ...) пропускает значение, если его пропустили все, а anyOf(p, q, ...), если пропустил хоть один. Внутри у них every и some по списку предикатов. Параметр типа A у всех трёх общий: связки не знают, про башни вопрос или про записи журнала, и годятся для любых предикатов.
Вопросы Рыдкулла теперь читаются почти как он их задал:
console.log(towersWhere(board, allOf(day, heardAtLeast(3))));
console.log(towersWhere(board, anyOf(night, not(heardAtLeast(3)))));
console.log(towersWhere(board, allOf()), towersWhere(board, anyOf()));
[ 'T-2' ]
[ 'T-2', 'T-3' ]
[ 'T-2', 'T-3', 'T-4' ] []
Последняя строка про пустые связки, и тесты котла её проверяют. allOf() без единого условия пропускает всё: возразить некому, как [].every(...) даёт true. anyOf() не пропускает ничего: согласиться некому, как [].some(...) даёт false. Пустые связки пригодятся, когда условия собираются по частям: начал с пустого allOf, добавил одно условие, и работает ровно оно.
Перевод с Ук
Ринцвальд торопится и кладёт в один сбор условие на башню и условие на запись журнала:
import { allOf, kindIs, quietFor, towersWhere, type Board } from './journal';
declare const board: Board;
export const silent = towersWhere(board, allOf(quietFor(60_000, 30_000), kindIs('sent')));
mixed.ts(5,74): error TS2345: Argument of type 'Predicate<Entry>' is not assignable to parameter of type 'Predicate<Tower>'.
Type 'Tower' is missing the following properties from type 'Entry': kind, frame, at
Колонка 74 это kindIs('sent'), второй аргумент allOf. Читаем снизу. У Tower нет полей kind, frame и at, которые есть у Entry. Кто-то собирался отдать башню туда, где ждут запись журнала. Строка выше говорит, где: предикат на записи не подходит туда, где ждут предикат на башни.
Откуда взялись башни? У allOf один параметр типа A на все аргументы. Первый аргумент, quietFor(...), это Predicate<Tower>, и он закрепил A = Tower. Значит, собранный предикат будет звать каждое условие с башней. kindIs('sent') получил бы башню и полез бы в её kind, которого нет. Помнишь из урока 13, что функция только берёт аргумент? Подставить вместо предиката на башни можно только предикат, который принимает башню, а предикат на записи журнала её не примет.
Компилятор не дал собрать условие, которое в рантайме молча отвечало бы false на каждую башню.
Под капотом
Каждый вызов функции заводит своё окружение: таблицу переменных этого вызова. Обычно окружение исчезает, когда функция вернулась. Но если внутри создана стрелка, которая читает эти переменные, стрелка держит ссылку на окружение, и оно живёт, пока жива стрелка. Замыкание это и есть пара «код функции и ссылка на окружение, где её создали».
Отсюда оба поведения из этого урока. day и night созданы двумя вызовами quietFor, у каждого своё окружение, поэтому пороги не смешиваются. А isQuiet Ринцвальда создан на верхнем уровне модуля, и его окружение общее с quietMs, которую он потом переписал. Ссылка ведёт на переменную, а не на её копию.
Котёл, этап 4
Котёл учится складывать условия. Ты заведёшь тип Predicate<A>, связки not, allOf и anyOf, предикаты-замыкания kindIs, quietFor и heardAtLeast, и функцию towersWhere, которая проходит доску башен с любым из них. Рыдкулл получит ответ на каждый свой вопрос, и ни один старый ответ не придётся переписывать.
Практика
Глубже в архиве
Как замыкания устроены в JavaScript и где они ловят на циклах с var, разбирает урок «Функции, области видимости, замыкания». В Haskell функции высшего порядка встроены в язык настолько, что без них там не пишут ничего; посмотри, как это выглядит, в уроке «Функции высшего порядка».
Дальше
Вопросы Рыдкулла складываются из маленьких функций, и котёл их варит. Матушка заглядывает в твою сводку смены и ведёт пальцем по строке:
const waiting = `ждут ответа: ${unconfirmed(record(record(journal, retry), ack)).length}`;
Как выстроить вызовы в том порядке, в каком они случаются, и не потерять по дороге типы, разберём в следующем уроке.