Композиция и итерация без циклов
Урок 04 закончил тем, что у нас есть чистые функции и привычка возвращать новые значения, а не мутировать. Хорошо, но изолированная чистая функция, ещё не программа. Программа собирается, когда мы начинаем их склеивать.
Сегодня мы возьмём g(f(x)) из первой секции прошлого урока и
развернём в читаемую форму. Покажем pipe и compose, разберём
каррирование, рассмотрим point-free стиль и equational reasoning. В
финале применим всё к итерации, увидим, что map, filter и
reduce, та же композиция, только над списком.
От вложенных вызовов к pipe
В прошлом уроке встречалась запись:
const result = transform(parse(read(input)));
Композиция уже здесь, но читается она “наизнанку”: сначала смотрим в центр, потом разворачиваемся наружу. Удобнее перевернуть слева направо.
Стандартная функция pipe в fp-ts принимает значение и набор
функций, и пропускает значение через них:
import { pipe } from 'fp-ts/function';
const result = pipe(input, read, parse, transform);
Та же программа, читается так же, как читается логика в голове: “взять input, прочитать, распарсить, преобразовать”. pipe свою работу делает за пять строк. Можно написать самому, без библиотеки:
const pipe2 = <A, B, C>(a: A, f: (a: A) => B, g: (b: B) => C): C =>
g(f(a));
// общий вариант через reduce
const pipeN = <A>(a: A, ...fns: Array<(x: A) => A>): A =>
fns.reduce((acc, fn) => fn(acc), a);
Сестра-функция compose отличается порядком: compose(g, f)(x) это
g(f(x)), тот же самый вложенный порядок, только без скобок.
const compose =
<A, B, C>(g: (b: B) => C, f: (a: A) => B) =>
(a: A): C =>
g(f(a));
const cleaned = compose(toLower, trim);
cleaned(' Hello '); // 'hello'
pipe подаёт значение слева, compose собирает функцию, в которую
значение можно подать потом. Обе делают одно и то же, выбирай по
эргономике текущего кода.
Каррирование
pipe ждёт от каждой функции одну сигнатуру: (x: T) => U. Один
аргумент на вход, один результат. Реальные функции часто принимают
два или больше. Чтобы они стали “pipe-friendly”, применяют
каррирование.
Из (a, b) -> c получаем a -> b -> c. Из (a, b, c) -> d,
a -> b -> c -> d.
// обычная сигнатура
const add = (a: number, b: number) => a + b;
// каррированная
const addC = (a: number) => (b: number) => a + b;
addC(2)(3); // 5
// частичное применение
const inc = addC(1);
inc(41); // 42
addC(1), уже не число, а функция (b: number) => number. Это
маленький, но важный сдвиг: функция, которой не хватает аргумента, всё
ещё значение. Её можно класть в переменные, передавать в pipe,
комбинировать.
const greet = (greeting: string) => (name: string) =>
`${greeting}, ${name}!`;
const ru = greet('Привет');
const en = greet('Hello');
ru('Линус'); // 'Привет, Линус!'
en('Linus'); // 'Hello, Linus!'
Каррирование ради pipe: мы хотим заранее зафиксировать greeting,
получить функцию name -> string, и положить её в цепочку. Без
каррирования, пришлось бы оборачивать вручную.
Partial application и point-free
Частичное применение, родственник каррирования: фиксируем часть
аргументов сейчас, остальные подадим позже. В каррированной форме это
просто вызов с одним аргументом, в обычной форме нужен bind или своя
обёртка.
const log = (level: string, message: string) => `[${level}] ${message}`;
// каррированная форма, удобно
const logC = (level: string) => (message: string) => `[${level}] ${message}`;
const warn = logC('WARN');
// обычная форма, неудобно, но можно
const warn2 = log.bind(null, 'WARN');
Когда функции каррированы, появляется point-free стиль: функцию-обработчик собираем как композицию, не называя промежуточное значение.
import { pipe } from 'fp-ts/function';
const trim = (s: string) => s.trim();
const lower = (s: string) => s.toLowerCase();
// с явным аргументом
const normalize1 = (s: string) => lower(trim(s));
// point-free
const normalize2 = (s: string) => pipe(s, trim, lower);
Антипаттерн: пихать point-free туда, где он мешает читать. Если каждая функция в цепочке требует мысленной расшифровки, явный аргумент с именем работает лучше. Как и любой стиль, point-free, инструмент, не догма.
Equational reasoning
Чистые функции дают одно мощное свойство, которое мы называли в уроке 04, ссылочную прозрачность. На её основе работает equational reasoning: программу можно переписывать как алгебраическое выражение, подставляя значения вместо вызовов и обратно.
Дано:
const double = (n: number) => n * 2;
const addOne = (n: number) => n + 1;
const f = (n: number) => addOne(double(n));
f(3) мы можем разложить:
f(3)
= addOne(double(3)) подстановка тела f
= addOne(6) вычисление double(3)
= 7 вычисление addOne(6)
Это не магия, это школьная алгебра. Важно то, что мы можем идти в обе
стороны. Если у нас в коде встречается addOne(double(x)) много раз,
мы спокойно вытаскиваем это в f и подставляем f(x) обратно. Знаем,
что поведение программы не изменится.
С нечистыми функциями этот фокус не проходит. Если double пишет в
лог, заменить вызов значением мы уже не можем, потеряем запись в лог.
Вот почему первые три столпа из урока 04 не косметика. Без чистоты
теряется equational reasoning, а вместе с ним, спокойный рефакторинг.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.