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

Функции как значения

middle~25 мин

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

В прошлом уроке у башни 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}`;

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