В прошлом уроке сводка смены стала конвейером, и в конвейере завелись переходники: (j) => record(j, retry). Стрелка ничего не считает, она только переставляет аргументы, потому что шаг pipe получает одно значение, а record хочет два. Пондер показал, что в Effect таких стрелок нет: Option.map(box, f) считает сразу, а Option.map(f) встаёт в конвейер.
Этот урок о том, как это устроено. Ты увидишь, что функцию двух аргументов можно разложить на две функции одного, разберёшься, где у функции должны стоять данные, и напишешь для котла dual: одну реализацию с двумя способами вызова, такую же, как в effect/Function.
Переходник
Вот вопрос Рыдкулла из урока 20 в конвейере:
const quiet = pipe(
board,
(b) => towersWhere(b, quietFor(60_000, 30_000)),
(codes) => codes.join(', '),
);
towersWhere принимает доску и предикат. В конвейере доска приходит от соседа слева, а предикат известен заранее. Стрелка склеивает одно с другим. Пока шагов два, это терпимо; когда их десять, стрелки занимают половину конвейера, а каждая из них это место для опечатки вроде (b) => towersWhere(board, ...), где взяли не ту доску.
Функция, которая ждёт
Предикат известен заранее, доска придёт потом. Так пусть функция сначала возьмёт предикат и вернёт другую функцию, которая ждёт доску:
const towersWhereC =
(predicate: Predicate<Tower>) =>
(board: Board): readonly string[] =>
towersWhere(board, predicate);
const quiet = pipe(board, towersWhereC(quietFor(60_000, 30_000)), (codes) => codes.join(', '));
towersWhereC(предикат) ничего не считает и возвращает шаг для конвейера. Превратить функцию двух аргументов в функцию одного аргумента, которая возвращает функцию второго, называется каррированием.
Ты уже делал это, не называя. quietFor(now, quietMs) из урока 20 возвращал предикат, который ждёт башню. Это каррированная проверка «молчит ли башня с такими-то настройками»: сначала настройки, потом башня. Замыкание помнит то, что дали первым вызовом, и ждёт остальное.
Где стоят данные
У каждой функции котла есть главный аргумент, над которым она работает: журнал у record, доска у towersWhere. Его называют self, как в Effect. Остальные аргументы это настройки: что дописать, какое условие проверить.
Порядок аргументов делит функции на два вида. Data-first: данные первыми, towersWhere(board, predicate). Data-last: сначала настройки, а данные последними, towersWhereC(predicate)(board).
Data-last создан для конвейера. Данные приходят в pipe последними, от соседа слева, поэтому их место в конце. Зачем тогда data-first? Затем, что одиночный вызов с данными впереди читается лучше: towersWhere(board, quiet) похоже на board.filter(...). Есть и вторая причина, поважнее. Она про типы.
Перевод с Ук
Возьми каррированный фильтр с параметром типа и вызови его дважды: в конвейере и сам по себе.
import { pipe } from './Function';
import type { Entry, Predicate } from './journal';
const keep =
<A>(predicate: Predicate<A>) =>
(items: readonly A[]): readonly A[] =>
items.filter((item) => predicate(item));
declare const journal: readonly Entry[];
export const inPipe = pipe(journal, keep((entry) => entry.kind === 'sent'));
export const sentOnly = keep((entry) => entry.kind === 'sent');
lonely.ts(12,41): error TS18046: 'entry' is of type 'unknown'.
Строка 12, колонка 41: entry в entry.kind. Ошибка короткая: тип entry это unknown, а у unknown поля kind не прочитать. Странно другое: строкой выше стоит та же самая стрелка, и к ней претензий нет.
Разница в том, откуда компилятору узнать A. В строке 11 keep(...) стоит в pipe, и pipe ждёт на этом месте функцию из readonly Entry[]. Компилятор умеет выводить параметр типа из того, куда пойдёт результат вызова: раз результат должен принять журнал, A это Entry, и стрелка получает entry: Entry. В строке 12 результат кладут в константу без типа. Подсказать некому, и A становится unknown.
Data-last форма без конвейера теряет тип данных: их ещё нет, и судить о них не по чему. Data-first форма такой беды не знает: журнал или доска стоят первым аргументом, A выводится из них раньше, чем компилятор дойдёт до стрелки. Поэтому Effect даёт обе формы, и каждая хороша на своём месте.
Обе формы сразу
Писать каждую функцию дважды, towersWhere и towersWhereC, не хочется. Нужна одна реализация, которая сама понимает, как её позвали. Если аргументов столько, сколько у полного вызова, считаем сразу. Если на один меньше, значит, self придёт позже, и возвращаем функцию, которая его ждёт. Число аргументов полного вызова называют арностью.
export const dual = <
DataLast extends (...args: any[]) => any,
DataFirst extends (...args: any[]) => any,
>(
arity: Parameters<DataFirst>['length'],
body: DataFirst,
): DataLast & DataFirst =>
((...args: Parameters<DataFirst>) =>
args.length >= arity
? body(...args)
: (self: unknown) => body(self, ...args)) as DataLast & DataFirst;
Прочитай сигнатуру, как в уроке 09. Два параметра типа: DataLast это функция для конвейера, DataFirst это функция для одиночного вызова. any в ограничениях здесь значит «какая угодно функция», и это честное место для any: настоящий тип придёт из аннотации, где dual используют. Результат это пересечение DataLast & DataFirst, функция с обеими сигнатурами. arity типизирована как длина параметров DataFirst, поэтому dual(3, (a, b) => ...) не соберётся. Внутри одна проверка: args.length >= arity.
Тип двойной функции записывают объектом с двумя сигнатурами вызова. В уроке 09 ты читал так Effect.map и пропустил, зачем ему две формы. Вот зачем:
export const entriesWhere: {
(predicate: Predicate<Entry>): (journal: readonly Entry[]) => readonly Entry[];
(journal: readonly Entry[], predicate: Predicate<Entry>): readonly Entry[];
} = dual(
2,
(journal: readonly Entry[], predicate: Predicate<Entry>): readonly Entry[] =>
journal.filter((entry) => predicate(entry)),
);
console.log(framesIn(entriesWhere(journal, kindIs('sent'))));
console.log(pipe(journal, entriesWhere(kindIs('sent')), framesIn));
[ 1, 2 ]
[ 1, 2 ]
Тело одно, и написано оно в форме data-first: self первым, настройки за ним. dual сам переставит аргументы для конвейера. Первая сигнатура в аннотации для pipe, вторая для одиночного вызова.
Под капотом
Спросить можно, но length врёт ровно там, где это важно:
console.log(['1', '2', '3'].map(parseInt));
console.log(
parseInt.length,
((journal: unknown, count = 2) => count).length,
((...xs: unknown[]) => xs).length,
);
[ 1, NaN, NaN ]
2 1 0
Первая строка это известная ловушка, и она о том же. map зовёт свою функцию с тремя аргументами: элемент, индекс и массив. parseInt принимает строку и основание системы счисления, и индекс попадает в основание: parseInt('2', 1) даёт NaN. Функция не знает, сколько аргументов ей передадут; это решает тот, кто зовёт.
Вторая строка показывает, что length считает параметры только до первого со значением по умолчанию, а остаточный параметр не считает вовсе. Двойная функция с необязательной настройкой по length не отличит «настроек не дали» от «данных не дали». Поэтому в котле арность задаётся числом, а настоящий Function.dual в Effect вместо числа принимает ещё и функцию-проверку: она смотрит на аргументы и решает, пришли ли данные.
Поэтому в эталоне стоит journal.filter((entry) => predicate(entry)), а не journal.filter(predicate): стрелка отдаёт предикату ровно один аргумент, и индекс от filter до него не доходит. Предикату на запись журнала индекс не нужен, а функции с необязательным вторым параметром он испортил бы ответ, как parseInt выше.
Где тут дыра
const dualAuto =
(body: (...args: any[]) => any) =>
(...args: any[]) =>
args.length >= body.length ? body(...args) : (self: unknown) => body(self, ...args);
const lastEntries = dualAuto((journal: readonly Entry[], count = 2) => journal.slice(-count));
console.log(lastEntries(journal, 1));
console.log(pipe(journal, lastEntries(1)));
[ { kind: 'sent', frame: 2, at: 200 } ]
TypeError: journal.slice is not a function
Одиночный вызов работает: последняя запись журнала. В конвейере lastEntries(1) падает. У тела count = 2, поэтому его length равен 1. Вызов с одним аргументом прошёл проверку 1 >= 1, и dualAuto решил, что это полный вызов: позвал тело сразу, с journal = 1. У числа нет slice.
Ринцвальд проверил одну форму из двух, и тип any не дал компилятору проверить вторую. У dual в котле арность записана числом и сверена с типом тела, а тип двойной функции записан аннотацией с обеими сигнатурами. Обе формы проверяются при каждом вызове.
Вот куда придёшь
Function.dual в Effect устроен так же. Так написаны почти все функции библиотеки, Option.map из прошлого урока в их числе:
import { Function, pipe } from 'effect';
const between: {
(from: number, to: number): (self: number) => boolean;
(self: number, from: number, to: number): boolean;
} = Function.dual(3, (self: number, from: number, to: number) => from <= self && self <= to);
console.log(between(5, 1, 10), pipe(5, between(6, 10)));
true false
Котёл, этап 6
Котёл учится двум формам. Ты допишешь в модуль Function функцию dual с той же сигнатурой, что в effect/Function, а в журнал двойную entriesWhere и framesIn. После этого конвейеры над журналом обходятся без переходников, а одиночные вызовы остаются короткими.
Практика
Глубже в архиве
В Haskell каждая функция каррирована с рождения: функция двух аргументов это функция, которая возвращает функцию, и data-last там порядок по умолчанию. Как с этим живут, показывает урок «Функции высшего порядка».
Дальше
Конвейеры над журналом стали короткими. Ринцвальд кладёт на стол свой тип для судьбы кадра:
type Delivery = {
readonly frame: number;
readonly sent: boolean;
readonly acked: boolean;
readonly sentAt?: number;
readonly ackedAt?: number;
};
Почему у кадра одна судьба, а не два флажка, и как сделать, чтобы невозможное состояние нельзя было даже записать, разберём в следующем уроке.